PlayServ SDKV2 PlatformV1
playserv.com
Start here

Not translated yet — showing English.

PlayServ SDK

The game backend that ships with the gameplay. PlayServ is a backend-as-a-service for live games: a studio runs its game's backend — data, players, rooms, matchmaking, commerce — without hosting one. The SDK is how your code, on the server and in the engine, works with that platform.

What follows is the short list of what is different here. Everything on it is decided once for a project and configured rather than written; what you call lives on the module pages, and every section ends by naming the page that owns it.

One declaration you write, and the five things that happen with no further code from you

The simulation is not your code

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Rooms, collision, locomotion, prediction and sync all run inside the platform. Your game is declarations (entities, maps, abilities, drop tables, sync policy), hooks (your rules, called at named steps), events (subscribe, don't poll) and operations (what you ask for or command). Every module page is organised around exactly those four.

What you skip is concrete: a game loop, snapshot assembly, a delta encoder, collision resolution, movement integration, reconnect handling, hit validation with lag compensation. A game like Tanks fits in ~300 lines. See Getting Started, which builds exactly that.

Mutating declared state is the network call

There is no send. You declare how a field synchronises — one attribute beside the field — and changing it is the network operation: deltas against the last acknowledged state, the aspect as the unit of policy, priority and send rate, the retained window, before- and after-change hooks. Nothing downstream is written by you.

The same move applies to everything else that is declared: an event, an RPC, a group, a leaderboard axis. A declaration is the input to the typed API, to the admin panel that renders it, and to the codegen for every binding — which is also why the declaration, not the generated code, is the thing you version. See Data & Subscriptions and Schema as Code.

Interfaces follow the actor, not the side

There is no client SDK and no server SDK. One SDK ships, and what a call may do is decided by the actor behind it — a player, a service, a bot brain, an operator.

The case it is built for is a player's machine that creates a room and then runs it: a master-client, holding the room-owner interfaces and nothing more. A room-visitor build has no kick and no close — not disabled, absent.

Rights are composed from atomic permissions, so there are no built-in role tiers, and a role gates data down to the row and the column. See Authority, and Access & Roles for how a grant is spelled.

One design, narrowed twice — over one stack

How it is written

The SDK is one design with two narrowing escapes, and nothing moves down a level until the level above cannot carry it. Common principles are identical in every binding. A language's shape takes only what its paradigm cannot express the common way. An engine's shape takes only what an engine reshapes on top of its language — in Unreal a declaration rides inside the engine's own reflection macro.

How it runs

Your game code addresses modules and nothing else. Modules are assembled from four primitives — events, RPC, data and subscriptions, groups. Under them sits the hub you never call: dependency injection, module mounting, the session, state recovery, message quality of service. Under that, transport adapters, one per protocol, and which one carries a call is not something your code decides or notices.

Both halves in full: How the SDK Is Built, ending at Under the Hood.

Modules compose; nothing subclasses

There is no base module to derive from and no hierarchy to extend — modules form a graph, because real features cross branches: matchmaking reserves seats in rooms, drops place items through the map, a chat lives inside a room.

"Inheritance" covers four different mechanisms here, and they are worth telling apart: an entity's RPCs are part of the entity; a preset is a named bundle of aspects, not a base class; overriding a platform step is an attribute on your replacement; a module borrows another through a decorator that narrows the borrowed interface. See Inheritance & Composition.

Who sees what is declared, not filtered on the client

At forty players a whole-room snapshot is fine; at two hundred it is not, and the fix is not a bigger pipe. Interest rules decide who receives which slice, and per-actor packets and broadcast are two delivery modes of one declared model — moving between them is configuration, not a rewrite. Distant things degrade through declared detail tiers before they disappear.

The part that is a security property rather than a bandwidth one: state that must not leak is never sent. Fog of war and owner-only fields are absent from the packet, not hidden on the client. Spectators, admins and replays get their wider view by holding a wider grant. See Visibility.

What survives losing a host

A host dying does not end the match. Room state is not copied between hosts while play runs — a single owner is what keeps ordering out of consensus — and what makes the match survivable instead is that the state is declared, and declared state is held outside the host. It is snapshotted on a declared interval, and a replacement resumes from the last snapshot.

So the replacement has the state whole — but as of that snapshot. Complete, not current. What it costs is the play since the last snapshot; what is not covered is anything you kept only in engine actors. A deploy uses the same mechanism, minus the loss: draining a host is the failover path run deliberately. See What Survives Losing a Host, and Rooms for the grace window a player re-enters through.

Any platform step can be yours

Every platform scenario is a chain of registered functions, and you replace a link or wrap it. Sign-in, entry validation, purchase, submission, upload — each is a named step, and your replacement is declared with an attribute, with versions chosen by condition and the platform's own step as the fallback.

This is what "customisable platform" means concretely, and it is what stands in place of shipping you our source: you replace the steps rather than fork the thing that runs them. See Extensibility.

One surface, six languages

One contract, six projections: C#, TypeScript, Python and Go on the server; generated Unreal C++ and Unity C# in the engine. Every code sample on this site shows all six, and where a binding has no surface for a step the tab names the reason instead of pretending — the step runs off the engine, or another actor holds the right to make that call.

Two consequences worth knowing before you pick a language: RPC takes SDK objects by reference rather than flattened DTOs, and the async primitives are core rather than bolted on — channels, streams and group addressing, so you can talk to a whole group and collect the answers, or consume a file as chunks while it is still uploading.

There is also a deterministic in-memory host that runs your game code with no backend behind it and time under your control, so a test is a test rather than a race — see Threads, Lifetime and Testing.


What is deliberately not in the SDK — deploys, billing, org and user administration — lives on the operator plane. The sidebar is the map of everything else; Getting Started is the shortest way in.

Start here

Not translated yet — showing English.

Getting Started

A playable arena (map, tanks, shooting, drops), declared end to end. Nothing below is a game loop: the simulation runs inside the platform, and this is all the code there is.

Before you start. A project with a dev environment (created on the operator plane, which owns that lifecycle), the playserv CLI signed in to it, and the SDK package for your binding — nothing else is installed into your game.

User flow

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

Every call you make is one of the examples below; the steps between them are the platform acting on what a declaration said. The ability, the stat and the drop-table in the figure are entity presets — declarations on entities, not modules of their own.

1. Declare the world

Entities are your schema plus their live aspects. One attribute per behaviour, beside the field it describes:

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

Mutating a [Sync] field is the network operation. There is no snapshot to assemble and no send call to make.

None of those types are yours to define, and each is owned by one page:

In the blockComes from
Vector3, Statyour binding's core package
Body, and the body shapesCollision
Motion, and the five movement modelsLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectthe entity presets that use them
EntryRequest, Verdict, StatEventhook payloads, handed in by the module you hook
SeatMatchmaking
Scope, the sync scopesVisibility
Tick, the tick ratesRooms

The enums are closed. A rule no member covers is written as a predicate rather than a new member: [Aspect("loadout", Visible = "owner == caller.player")] is how per-field visibility is expressed when Scope.Owner is not quite the rule you meant (Data).

2. Declare the room

A room template says what a session is, and names the declarations it draws on. There is no room class to inherit and no tick method to fill, because room internals are the platform's:

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

The quoted ids are content keys, not free text:

KeyWhat it names
rocka prop in the map's obstacle set (Map)
ammo.shell, railguncatalog items (Catalog & Commerce) — which is also how the shot debits ammo and the pickup lands in a bag
battle, arena, crate-loot, shellthe keys these four declarations register

playserv push refuses a declaration whose key does not exist in the environment it targets, so a mistyped key fails at deploy time instead of at the first cast. Wherever the template lives, it stays retunable without an engine redeploy: the pushed model is what live-ops edits in the panel.

3. Write your rules as hooks

Hooks are cloud functions the platform calls at named steps. Typed in, typed out — no context bags, no loggers in the signature:

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

A Before gate can refuse the step; an After observer runs once it has committed and cannot. So a starter pack that did not land costs 20 shells, not the sign-in. Extensibility has the rest.

Almost nothing in that block does the work it appears to do:

The lineWhat actually performs it
Stats.Depleted firesthe stat reaching its floor — 0 for Hp, since the declaration set only Max
the death transitionAtMin = "death" in section 1; the hook adds the consequence, not the transition
the damage[Effect(Damage = 35)] on the shell, applied by the platform on a hit
RollAtgenerated onto the [DropTable] declaration — which is why it is partial, and why the Go tab reads drops.RollAtCrateLoot
the pickupdriving over loot transfers the items into the player's inventory atomically; that transfer is the changed event a HUD renders

Generated types for the engine

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

Run it (or let CI run it) after every schema push — types are regenerated, never hand-edited, and the generated Tank is the pushed Tank. playserv push reads an engine project exactly as it reads a server project: the UHT specifiers and the C# attributes are the declaration, so pointing the CLI at the UE or Unity project is the whole export step. Which thread a callback lands on, and when a subscription ends, are fixed with the runtime model — Threads, Lifetime and Testing.

4. Connect a client

The client API is symmetrical: the same modules, and what a build may call is decided by the key it runs under. An engine build carries a player key — projectKey here, the credential for one project and one environment, issued in the panel and shipped inside the build. It names no roles: roles resolve server-side on every request, and the player behind them arrives with SignIn. The engine bindings are first-class here; the server bindings drive the same surface headlessly (a bot brain, a load test, an ops tool):

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

Four calls, and each answers something different:

CallWhat it answers with
Findone ticket placed, and a seat reserved in a room. The reserve is held for the term the template declares; lapsing it costs the seat, not the right to play
Joinresolves once the room's current state has arrived. The Tank the template spawns for an entering member is part of that state, so My<Tank>() answers on the next line and everything after Join is live traffic
Castfiring is the ability verb, not a second surface: a [Projectile] declaration is an ability with ballistics on top, so Cast checks cooldown, ammo and target the same way it would for a dash or a heal (Entity Presets)
Abilitiesgenerated — codegen collects the declared abilities and projectiles into one type per binding

Refusals arrive as the platform's typed Problem — a code plus a reason for a human. C#, TypeScript, Python and Unreal raise it; Go returns it as the error value, which is why every call in that tab is checked. An Unreal dedicated server or a master-client runs the same binary under a host key instead, and its roles carry the mc rows: Rooms → Hosting a room is that surface end to end, Access is where the keys and roles behind it are declared.

5. Push and play

playserv push          # schema + declarations + hooks, one deploy
playserv open battle   # a dev-env room, live in the panel

playserv push scans the project it runs in — attribute hooks and declarations — and deploys to the environment you target (--env dev by default). playserv schema push alone moves just the model, and playserv schema diff is what a push is diffed against (Schema).

A push lands whole or not at all, and it is refused rather than merged if the deployed schema moved since your diff. A change that would break existing data does not ride the push at all: it becomes a migration you read first and then run or cancel (Schema). Moving a model from dev to prod is an operator-plane act rather than an SDK call (the operator plane).

playserv open battle creates one room from the pushed battle template and opens it in the panel, where the room's state and its members are inspectable while you play against it. The panel now shows the template, the map, the drop-table and the hooks: the same model you authored in code, editable there too.

The numbers you did not choose

Capacity = 8, Hz30, Hz = 10 and Cooldown = 1.5f are this game's tuning, not ceilings. The platform's own limits sit above them, and each is declared with what the caller observes at the boundary:

At the boundaryWhat the caller gets
a join past capacity, or into a closed roomconflict — worth retrying when a seat frees
room creation past the per-project or per-actor limitrefused, and nothing already created is disposed
creating rooms or signing in too fasta rate-limit refusal carrying the time to wait
an event payload over the room's caprefused before it is sent, never truncated
a read past a role's row ceilingthe ceiling's worth of rows, plus the marker saying it was cut

The numbers themselves are per environment and land with the platform's limits; the behaviour at the boundary does not wait for them (Rooms, Access, Auth).

Where to go next

  1. Examples, the section straight after this one: a leaderboard in Tanks, health crates in Tanks, or the daily-tournament recipe for the meta loop — one real feature each, every step linking to the module page that owns what you just used.
  2. How the SDK works, when the shape of it starts to matter more than the next feature: Core Concepts is the dictionary, and four articles answer who is calling (Authority), how a grant is spelled (Access & Roles), what the SDK is made of (How the SDK Is Built) and how it is executed (Threads, Lifetime and Testing).
  3. Then the modules. Every module page has the same anatomy — thesis, actors, when to use it, user flow, examples, model — so the second reads faster than the first and the fifth takes minutes. Entity and Data are the two everything else leans on.

Reading paths by role

Whatever your role, read Authority first — one SDK and a grant per actor is the shared prerequisite — with Core Concepts open beside it.

You areRead, in order
Game client dev (Unity · Unreal client · TS)Auth → Matchmaking → Rooms → Entity → Data, then per feature: Inventory · Leaderboards · Messaging · Profile
Server dev (C# · TS · Python · Go)Schema → the building blocks → Entity → Extensibility → Access, then the modules whose declarations you own: Rooms · Matchmaking · Leaderboards · Commerce
Unreal dedicated-server devRooms (Hosting a room) → Bots → Locomotion · World Objects → Map → What Survives Losing a Host
Examples

Not translated yet — showing English.

A leaderboard in Tanks

Tanks, the sample arena from Getting Started, has no leaderboard. This lesson, which you can take any time after Getting Started, adds a weekly kill board in three steps: declare the board, submit from the kill hook, read it in the client. Each step links to the module page that owns what you just used, so the lesson teaches by pointing rather than by repeating.

Step 1 — declare the board

A board is a declaration: which field ranks it, how repeat submits combine, when it resets, and who may submit. Aggregation.Increment adds each submit to the running total, so one kill is one point. Submit.ServerOnly is the default and it closes the board to clients, which is what makes step 2 the only way in.

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

Push it with playserv push and the board appears in the panel, empty, with its Monday cycle already scheduled — Monday 00:00 UTC, since schedules are UTC. The axes you did not set keep their defaults. See Leaderboards for the full axis list — owner, order key, display fields, tournament rules.

Step 2 — submit from the kill hook

Tanks already ends a life through the HP threshold declared on the tank: at zero HP the death transition fires and the platform calls the hook after it. The hook is a cloud function, typed in and typed out, so submitting a kill is one line inside it.

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By is the attacker the damage carried, so no bookkeeping tracks who shot whom. e.Id is the event's own id, and passing it as the idempotency key is what an Increment board needs: a re-delivered kill event counts once, not twice. The hook point, the ordering guarantees and the veto contract are Extensibility; the threshold that fires it is a stat preset on the tank, and the score row it writes is ordinary data you can query.

Step 3 — read the board in the client

Two reads cover the whole UI: the top of the board and the window around the local player — five rows above, five below, plus your own. Both come back as ranked entries with kills and display name, ready to bind to a list. A subscription keeps the panel current while the match runs, and it delivers the local player's rank only.

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

Monday's reset closes the cycle instead of deleting it, so last week's table stays readable by its label — the same Top call with a cycle: argument. A reward hook on the cycle close is the natural fourth step, described on Leaderboards.

Where to go next

Examples

Not translated yet — showing English.

Health crates in Tanks

Before
a tank that takes damage stays damaged until it dies. There is no way back, so every fight is a countdown and the arena has no reason to move through.
After
health crates appear around the arena, spaced apart and away from whoever is fighting. Driving over one heals you. Nothing else about the game changes — and neither does the room's code, because there isn't any.

This is the second Tanks lesson. It takes three steps and no new module: a declaration for the crate, a declaration for where crates appear, and one hook for what picking one up does. Take it after Getting Started, in either order with the leaderboard lesson.

Step 1 — declare the crate

A crate is an entity with two presets applied and a body that reports contact without stopping anyone. Response.Pass on the pickups layer is what makes it a pickup rather than an obstacle: the contact is reported, the motion passes straight through.

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

Two things you did not have to write: where the crate is drawn (the client already renders declared world objects) and how its position reaches the clients — [Sync] is the network call.

Owned by Entity Presets and Collision.

Step 2 — declare where crates appear

Placement is a declaration too, and this is the step that decides whether the feature feels fair. Spacing keeps crates from clumping, distance from players keeps them from spawning into a duel, and the no-repeat rule keeps the same spot from being the answer every time.

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

Valid positions come from Map — the table asks for a spot on the ground layer and the map answers with one that is actually reachable, so a crate never lands inside a wall.

Owned by Entity Presets and Map.

Step 3 — heal on pickup

One hook, and it is the only code in the lesson. It runs on the platform as a cloud function, which is why it appears on neither engine tab.

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

Three things this hook gets for free, and each is why the lesson is this short:

  • The refusal is typed. A tank at full health gets already at full health with a reason a client can show, and the crate is still there for someone who needs it.
  • Adjusting a stat is server-authoritative. It is not something a client can ask for, so there is no anti-cheat exception to write for pickups.
  • The HUD updates without being told to. Adjust emits changed; the client is already subscribed to the tank's declared stats. You did not write a network message.

Owned by Extensibility and Entity Presets.

What changed, and what didn't

BeforeAfter
a damaged tankstays damaged until it diescan recover by moving through the arena
room codenonestill none
new modules mounted—none: two declarations and one hook
anti-cheat exceptions—none: healing is server-authoritative like every stat change

Where to go next

  • Entity Presets — the drop generator, world objects and the stat model this lesson leaned on, all three of them presets of entity rather than modules.
  • Collision — layers, responses, and the difference between a reported contact and a blocking one.
  • Map — how a valid position is chosen, and what "reachable" means.
  • Extensibility — every hook point in order, with the veto contract.
  • A leaderboard in Tanks — the other Tanks example.
Examples

Not translated yet — showing English.

A daily tournament

What you get: a daily tournament with an entry window, seeded rooms and a prize payout — built entirely from declarations and hooks on modules you already have pages for. Nothing here is a new concept; it is leaderboards, matchmaking, rooms, commerce and messaging composed for one meta loop.

Step 1 — declare the board with an entry window and attempt limits

A tournament is an ordinary leaderboard declaration plus participation constraints: an entry window, a cap on entrants and attempts per cycle. Nothing about scoring changes — the order key, the aggregation and the reset stay exactly as on any board.

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

Push it and the panel shows an empty bracket with its window scheduled. joinRequired: true makes the entrants a membership rather than everyone who plays, so a submit from a non-entrant is refused. See Leaderboards for the rest of the axis list and for what each constraint does at its boundary.

Step 2 — the window opens: a party joins, rooms get seeded

Once the entry window opens, players queue exactly as for any match: create or join a party, then one Find call. The matchmaker places the party into a tournament bracket and Rooms seeds the match — the same placement-and-seat path every match uses, just scoped to the tournament's queue.

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

The caps from step 1 belong to the board, not to the matchmaker: the queue places parties, and it is the board that meets an entrant over its cap or a player past their attempts. Entrant 65 of 64 is refused as a conflict with nothing evicted, and a fourth submit in one cycle answers "attempts exhausted" — also a conflict, cleared by the daily reset rather than by asking for a permission.

Step 3 — scores submit via the on-dispose hook

Rooms don't self-report a winner into a leaderboard; that link is a hook, same Extensibility contract as everywhere else — typed in, typed out, no context bag. The room's on dispose hook (Rooms) is the last thing that runs with the match's final state in hand, and it submits from there.

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner and FinalScore are fields this game's own room template declares in its state — the platform adds nothing to the snapshot (Rooms is where template state is declared). The dispose hook (Rooms.Disposed) hands the final snapshot over, so the match is never recomputed. The pre-submit hook from Leaderboards still runs first — a bracket score is subject to the same correct-cap-or-reject contract as any other submit.

Step 4 — the cycle closes: rewards granted, player notified

The daily reset from step 1 closes the cycle exactly as any leaderboard reset does, and fires CycleClosed carrying the closed cycle's label — that label is what makes the hook read the table that just closed rather than the empty one that just opened.

One hook does the rest: it grants the prize through Commerce's entitlement path and pushes the result through Messaging, so there is no separate payout job to run. A notification is addressed to one actor, so the top 8 is a loop of eight, each carrying its own rank argument into the template.

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

The numbers in step 1 are seed values: LiveOps retunes the window, the entrant cap and the attempt count in the panel, and the next deploy does not silently overwrite the change. Making this a weekly tournament is one edit — Reset.Daily() becomes Reset.Weekly(DayOfWeek.Monday), and steps 2 to 4 stay as they are.

Where to go next

How the SDK works

Not translated yet — showing English.

Core Concepts

The words the rest of these pages use without stopping to explain them. A module page assumes you already know what an actor is, or an aspect, or a room — here each one gets a one-line definition and a link to the page where the mechanism behind it actually lives. Read it once before the module reference, or come back when a word turns out to be carrying more weight than you expected.

Three things are too large for an entry and have a page each: Authority — who is calling, and what that alone decides; How the SDK Is Built — what the SDK is made of; Inheritance & Composition — how modules build on each other. In that order they read as one argument.

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

The four surfaces

Every module exposes exactly four things, and every module page is organised around them. This is the programming model:

SurfaceMeaning
Declarationswhat exists and how it behaves, authored in code or in the admin panel; the same model either way
Hooksyour rules, called by the platform at named steps; deployed as cloud functions
Eventswhat the platform tells you happened — subscribe, don't poll
Operationswhat you ask for or command, from a function or a client

Actor

Who is making a call. What a call may do is decided by the actor behind it, never by which build the code was compiled into — the argument is Authority, the mechanism (atomic permissions, composed roles, InterfaceGrant) is Access & Roles.

These docs draw actor names from one catalogue — the presets the platform ships. It is a set of presets, not a closed list (a project names its own actors), but every scheme's actors line, Who-does-what row and flow-diagram chip in these pages uses these exact spellings:

player · backend-service · operator · host · moderator · schema-author · architect · bot-brain · room-owner · room-visitor · entry-validator · spectator · match-organizer · warehouse-keeper · seller — and any when a page means all of them.

A page may additionally introduce a scene role for one diagram — a descriptive participant like member or attacker — as long as its own prose or Who-does-what table introduces it first.

Runtime surface

Where code runs. These four tags are used in every operations table:

TagSurface
fnCloud function (C# · TypeScript · Python · Go). Server-authoritative; the primary home of your rules
clGame client (Unreal C++ / Unity C#). Symmetrical API; roles unlock less
mcThe room-host surface: a master-client (a client that owns a room) or an Unreal dedicated server under its host key
admAdmin panel / CLI / MCP — where the SDK and operator plane share a model

This is the axis that is constantly confused with the one above it. Where code runs and which actor interface it holds are two separate questions: the same code holds the same rights wherever it is placed, and only the grant differs.

Project & Environment

A project is one game's backend, with a schema and its data in isolated environments (dev, prod). Every call runs inside a project + environment.

Entity

The central noun. An entity is a schema declaration plus its live aspects: data 0..*, states 0..*, RPC 0..*, events 0..*, hooks, and change history. A tank, a door, a stat bar and a quest are all entities, differing only in which aspects they carry. Common combinations ship as presets (GameObject, Stat, Character, Interactable, Projectile). See Entity.

Expected state

A transition request may name the state it expects, and then it is that state or a refusal. A request that names none is evaluated against the state the machine holds when the platform processes it — never against the state at the moment it was sent.

The reply describes that moment and promises nothing about later: someone else's transition landing while the reply is in flight leaves the reply true and does not cancel it. So name the expected state when the outcome depends on what was there before, and otherwise do not read the reply as a snapshot that outlives the call.

Room

A room is a game session, not a place your code runs. The platform doesn't care what hosts it: a dedicated server, a master-client, or the backend itself. Room internals are ours; you drive a room from outside, from cloud functions and clients, through declarations, hooks, events and operations. See Rooms.

Channel & Stream

The async primitives under everything. A channel is an addressable pub/sub topic: a room, a group, an entity, or your own. A stream is a chunked flow in either direction: files are consumed as chunks arrive, queries can stream, and an RPC can fan out to a group and collect the answers. See Core.

Primitive

One of the four bricks every module is assembled from: events (declare, emit, subscribe), RPC (invoke across the wire), data and subscriptions (sync mechanics) and groups (one list, many listeners). A room, a chat and a matchmaking pool are the same group primitive under different rules. If a feature cannot be expressed through the four, that is a design defect and not an argument for a fifth.

Hook contract

One contract everywhere: a before hook runs ahead of validation, receives the typed payload, may mutate it or reject; an after hook runs once the operation has committed, receives request and result, and can only add side effects — it can never fail the operation. Hooks are ordered; every registered platform step can carry them. See Extensibility.

Delta & Revision

Clients receive state as deltas: only changed fields, encoded against the last state the receiver acknowledged. Every record carries a revision; conditional writes reject on mismatch. One versioning concept serves sync, concurrency and history. See Data. (What Unreal calls replication — which client sees which state, how often — lives here and in Visibility and Prediction. The What Survives Losing a Host page is the other one: which machine owns an entity, and which owns it next.)

Tick

Rooms simulate on a fixed step. Every state change is stamped with its tick; sync, prediction, lag compensation and history all count in ticks rather than wall-clock. Data carries its true event time — that is what makes rewind and reconciliation exact. See Prediction.

How the SDK works

Not translated yet — showing English.

Authority

Authority is an abstraction, not two builds of the SDK. There is no client SDK and no server SDK. There is one SDK, and what a given call is allowed to do is decided by the actor making it.

A master-client is neither a client nor a server

A player's machine that creates a room and then runs it — a master-client — holds the room-owner interfaces and nothing more. It is not a server: it cannot do everything a server can. It is not an ordinary client either.

A dedicated server is the same shape from the other side: the same client without the rendering, needing no separate SDK. What separates the two is trust, not construction, and trust is carried by the grant.

Interfaces follow the actor, not the side

A module does not expose "the client API" and "the server API". It exposes what a room-owner can do, what an entry-validator can do, what a seller can do. Client and server are plumbing; actors are the domain. Inside each module page the surface is grouped the same way — this is for these needs, that is for those.

A role is the right and the classification, both

There is exactly one dimension here. A role carries what an actor may do, and it is also how you say who something is addressed to. There is no second axis of tags or labels beside it: one thing to declare, one thing to check, one thing to read in the admin panel.

whoami is how code asks. It reports the actor and the interfaces that actor unlocks right now — not a static list baked into the binary at build time.

Where code runs and which actor it holds are separate questions

QuestionAnswers
Where does this code execute?a cloud function · a game client · a master-client or dedicated server host
Which actor interface does it hold?player · room-owner · entry-validator · seller · moderator · backend-service · …

Laid out as a grid, the two axes are independent and every cell is reachable:

cloud functiongame clientroom hostadmin
player✓✓✓—
room-owner✓✓ — a player's own machine, hosting✓—
backend-service✓—✓✓

The emphasised cell is code running on a client and doing a server's work. It is named — room-owner — and it is a grant like any other.

Any combination is legal. A cloud function is not automatically privileged, and a client is not automatically limited: rights come from the grant, and the grant is declared. The code's rights are the same wherever it runs — only what it was granted differs.

Revocation takes effect without reissuing the credential

A credential names an identity. It does not carry a role list. Roles resolve server-side, per request, which means the client never holds the proof of its own permissions and there is nothing stale to keep presenting after a revocation.

Two consequences, both stated where they belong:

  • Revoking a role takes effect without reissuing the credential — see Granting a role.
  • It becomes observable no later than the declared staleness bound on the rights cache. We do not promise instant.

Where authority is declared, not inferred

  • A room type declares its authority mode, and there is no default: either our simulation runs the tick, or an external authority does — the studio's game server, or a player's client as the master-client. That is Who runs the tick.
  • How far an external authority is trusted about the outcome is a separate declaration on the room type — accept it, check it with a hook, or do not accept it. Again no default.
  • Which credential resolves to which role, and what a host key is, is Access & Roles.
How the SDK works

Not translated yet — showing English.

Access & Roles

Roles are composed, never hardcoded. Atomic permissions compose into roles; roles gate data down to row and column, and decide which module interfaces a build even sees. It replaces the client/server key split: a credential names an identity, its roles resolve per request.

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

When to use it

  • You need a credential narrower than "client" or "server" — composed roles resolve behind it per request.
  • Data access must stop at rows and columns: region scoping, PII masks, read-only contractors.
  • A build should only see the interfaces its role unlocks — kick/close simply isn't there for a visitor.
  • Your UI must grey out buttons honestly — CanI evaluates the same policy the server will enforce.
  • Skip it when the shipped presets (player, room-owner, seller, …) already match your actors — every module respects them by default; the full catalogue lives on Core Concepts.

Who does what

ActorOn this page
operatordeclares roles and policies, sets row/column limits, grants roles, issues keys
match-organizerthe tournament staff of the flow below: holds a composed key, gates entries, cannot refund
every actorchecks CanI before acting; sees only its unlocked interfaces

At a glance

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

An atom is a pair — a resource and one of four verbs: read, write, execute, administer. The verb set is fixed, and a case that does not fit splits the resource instead of growing the list. That is why gating somebody else's entry is Rooms.Membership.Administer rather than a ValidateEntry verb of its own: acting on another actor's membership is administration, while joining yourself is Rooms.Membership.Write on the same resource.

The model

The data ACL is role × operation × row predicate × field mask — one model, identical whether it was authored in code, through the API, or in the panel's role grid.

What access is built from.

TermWhat it is
atomone resource × verb pair. The four verbs are read (fetching, selecting, subscribing), write (creating, changing, deleting, and acting for oneself — join, leave), execute (invoking a function, applying an ability) and administer (acting upon others: kick, close, force a state change)
rolea named set of atoms. It may include another role, and a cycle in inclusion is a configuration error rather than something resolved at runtime
role presetships over atoms and keeps working unchanged for already-deployed consumers. A starting point, not a constraint: a project declares roles of its own out of the same atoms
row predicatewhich rows — a boolean predicate over session values
field maskwhich fields, declared per role and operation. A field a role may not read is not returned at all, rather than returned empty

What a credential resolves to.

CredentialWhat it unlocks
player keyan engine build carries it; the player behind it arrives with sign-in, and the build sees the cl rows of every operations table
host keya dedicated server or a master-client holds it, and its roles unlock the mc rows
pushed coderuns as the project's backend-service role — that is what a leaderboard's Authoritative = true checks
a registered hookgrants nothing extra: your function keeps the role it deployed under

The tag legend (fn / cl / mc / adm) is owned by Core Concepts.

What is true of every check.

AlwaysWhat it is
a credentialcarries no role list: it names an identity, and roles resolve server-side per request. A revocation invalidates the cached resolution at once rather than waiting out its declared staleness bound
delegationchanges scope, never capability: acting on behalf of a player changes which rows are visible and to whom a write is attributed, and grants no operation the actor did not already hold
the verbanswers what kind of effect, and the predicate answers which rows. If two cases differ only in whose row it is, that is a predicate; if the effect itself differs, that is another operation and possibly another verb — which is why "kick" is administer rather than write with a wide predicate
a hidden rowanswers not found: a refusal must not become an oracle for existence
an owneralways sees itself, whatever else a predicate says
visibilityis not security — a channel optimisation and a permission are different mechanisms, and neither substitutes for the other
a disabled modulehas no surface: whether a module is enabled is a property of the build, so codegen emits nothing for a disabled one and an unavailable call is a compile error rather than a runtime refusal
a module's surfacefollows the actor rather than the side (Authority argues it, and this is its mechanism): a room-visitor build sees join, leave and read, a room-owner build additionally sees kick, close and configure, and whoami reports which interfaces the current actor unlocks
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

Who hands out a role. Granting and revoking a role to a player, and the project's declared default role for a new one, are operations on Auth & Players — that module owns identities, and a role is resolved by the identity in the credential. This page owns what a role is; that page owns handing it over.

Errors

  • What a predicate hides answers not found, not forbidden — otherwise the refusal itself tells the caller that the thing exists, which is exactly what hiding it was for.
  • A right the caller does not hold answers forbidden where the subject's existence is not a secret, and it names what was missing rather than failing blankly.
  • A field outside the mask is absent from the answer, not present and empty: an empty value and a masked one would be indistinguishable.
  • A role including itself, directly or through a chain, is a configuration error — refused as a declaration rather than resolved at runtime.
  • Delegation never widens capability: a call the actor could not make on its own behalf is refused when made on a player's.

Limits

Each ceiling names its behaviour at the edge; the numbers land with the platform limits chapter.

  • The size of a selection under a row predicate is bounded, and the ACL model declares that bound rather than discovering it. A read above the ceiling is answered with the ceiling's worth of rows plus the marker saying it was cut, never with a silent short page.
  • The staleness bound of a resolved permission is declared, and a revocation does not wait it out — it invalidates at once.

User flow

One tournament-organizer key, from role composition to a live permission change.

How the SDK works

Not translated yet — showing English.

How the SDK Is Built

Two questions, and both have short answers. How is the SDK written — why one idea looks slightly different in Python and in Unreal C++. How does the SDK run — what sits between your call and the wire.

Written from the general to the particular

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

The SDK is one design with two narrowing escapes, and the order is the rule: nothing moves down a level until the level above genuinely cannot carry it.

LevelWhat lives here
The common principlesIdentical in every binding: behaviour is declared as an attribute beside the thing it describes; every declaration you push is a declaration the admin panel renders; your code addresses modules and nothing else.
The language's shapeOnly what a language's paradigm cannot express the common way. C# has attributes and Python has decorators — the same declaration, spelled the way each language already spells that idea. A language with no such construct carries the same declaration a different way, and that carrier is named where it applies rather than assumed.
The engine's shapeOnly what a game engine reshapes on top of its language. Unreal C++ is not plain C++ — it has its own object model and its own build-time reflection, so a declaration there rides inside the engine's own reflection macro, in the position where that macro already takes specifiers. Unity C# is not server C# either: an older runtime, a smaller base library.

Read top down, this is why the six tabs on every example are not six different APIs. They are one API, spelled six ways, and the differences you can see are the two lower levels showing through.

How it runs, from your code downwards

Your game code sees modules, and the vocabulary stops there. That is the contract of the top level.

  • Modules are what you address. They form a graph, not a tree, and what that buys is Inheritance & Composition.
  • The four primitives are what modules are assembled from — events, RPC, data and subscriptions, groups. A room, a chat and a matchmaking pool are the same group primitive under different rules. If a feature cannot be expressed through the four, that is a design defect, not an argument for a fifth.
  • The hub is underneath, and you never call it: dependency injection, module mounting, the user session, state recovery, and message quality of service. It is named once on Under the Hood.
  • Transport adapters sit at the bottom, one per protocol, and the hub hides them completely. There will be several — WebSocket, our own UDP, HTTP — and which one carries a call is not something your code decides or notices.

What a module tells you about delivery is its quality of service — at-least-once, or at-most-once — and nothing else. How the bytes got there is the part we reserve the right to make faster.

What you get out of this

  • One SDK, not a client one and a server one. What a call may do is the actor's grant, not a build flag. That is Authority.
  • A declaration is the input to everything. Push it and the typed API appears, the admin panel renders it, and the codegen for each binding follows. See Schema as Code.
  • Modules compose rather than inherit. How, and what "inheritance" honestly means here, is Inheritance & Composition.
How the SDK works

Not translated yet — showing English.

Threads, Lifetime and Testing

You own the loop. We deliver into exactly one place, and never behind your back. The SDK starts no thread you have to know about, hands you no lock, and calls your code from a single context you chose at startup. Call us from any thread you like; we call you from one.

One delivery context, and you own the loop

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

An instance declares exactly one delivery context — the single place where all of its handlers run. It is fixed when you initialise and does not change for the life of the instance. An event, a data delta, the outcome of a call: all of them arrive there and nowhere else.

There are two shapes of it, and you pick one at startup:

  • You pump it. The runtime does nothing on its own; you drain pending deliveries from your own loop. This is the shape an engine wants — deliveries land on the game thread, in a frame you chose.
  • We own it. The runtime holds one dedicated thread of execution. This is the shape a console host or a dedicated server wants.

Neither is a fallback for the other, and there is no third option involving a thread pool: the number of threads the runtime holds is never a question your code has to ask.

The context is never an argument. No handler takes a "which thread am I on" parameter, and there is nothing to query. Where your handler runs is a property of the contract, not data of the call.

Starting and stopping are explicit

Initialisation is a call you make, and it answers with an outcome. Nothing initialises lazily on first use — that is forbidden rather than merely discouraged, because a lazy start moves the one place where a disabled module is visible into whichever arbitrary call happened to be first, where it reads as that call failing.

A disabled module is named at startup, and the outcome says which of two things happened: the whole dependent chain is off, or you are running with less, plus the list of what is unavailable. There is no silent third case.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Shutting down does not cancel anything. Shutdown is explicit, complete and idempotent — after it succeeds no handler of that instance is called again — but it says nothing about work already in flight. An operation you started before shutting down stays discoverable by whatever means that operation named. If you need to know whether a purchase went through, shutting down is not how you find out.

Every handle has one declared end — and it is never the garbage collector

A subscription, a deferred-work descriptor, a session: each is a handle, and each has exactly one end that the contract names. Releasing is idempotent, so releasing twice is not an error.

Three consequences that are easy to get wrong:

  • The end is never a finaliser, a destructor or a scope. A handle you drop on the floor stays open. That is a bug in your code, not something we quietly reclaim — because a lifetime that depends on the language would be a different lifetime in every binding.
  • Using a handle after its end is a declared refusal, with a code. Not an empty result, not undefined behaviour, and not a generic disposed-object error that carries nothing you can act on.
  • A dropped connection is not the end of a handle. A subscription survives a disconnect and keeps receiving after reconnection. Handles end for the reasons the contract names, and losing the network is not one of them.

No handle outlives the instance that issued it: once you shut down, every handle it gave you is at its end.

Calling from a handler is fine; waiting inside one is not

Call the surface from any of your threads. Every handle is free-threaded, and that is a promise rather than a property of today's build. You will never take our lock, wait on our barrier, or be told to call something "under a lock" — no synchronisation primitive is part of the surface at all.

Handlers of one instance are serialised: two never run at the same time, and order within one stream is preserved. So a handler needs no locking of its own.

Serialised does not mean deduplicated. Order is one promise; how many times a message is delivered is a different one, declared on the message type. Under at-least-once you will see the same message twice, and the deduplication key that always travels with it is how you tell.

Starting an operation from inside a handler is legal and cannot deadlock. Its outcome, though, never arrives inside that same handler — it comes back as a separate delivery on the same context. Going inwards is allowed; turning round inside is not.

Blocking the delivery context is forbidden, and the ban is not advice. Waiting on the network, waiting on someone else's lock, synchronously waiting on your own call: all forbidden inside a handler. The prohibition has a symptom — a handler that holds the context beyond its declared budget produces either a declared degradation of delivery or a declared refusal. What it never produces is a silent slowdown you get to discover in a player's session.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

"Cancel" is two different things

One word in most languages, two operations here, and the difference is observable:

You wantWhat it is
stop delivering to melocal. Always succeeds, including with the connection down. Releasing a subscription is this.
stop the worka request to the platform. Idempotent, and it promises nothing about whether the work happened.

The second is the one people get wrong. Cancelling accepted work is a request that may not arrive in time — exactly like a timeout, which also does not mean "it did not apply". After either kind of cancellation, the outcome of a started non-idempotent operation remains discoverable by the means that operation named.

And the two failures are distinguishable: cancelling a call that never reached us is a local failure; cancelling work we had accepted gives you a terminal state from a declared set.

What you receive is a copy of the past

A value delivered to your handler does not change afterwards. We never hand out a live reference into our own state, so nothing you are holding mutates between two lines of your code.

Keeping a delivered value beyond the handler is therefore safe — but what you kept is an observation of a moment, not a window onto the present. Deltas may be merged on the way to you, so a list of values you saved is not a history of what happened.

You also never own our buffers. There is no rent-and-return, no assemble-then-send: changing declared state is the network operation.

Testing: an in-memory implementation, not a mock

There is a full in-memory implementation — same surface, same set of declared outcomes, no network. It is a separate thing you depend on, not a flag on the production runtime.

  • It is not partial. An operation it does not support is refused with a declared code, never answered with an invented success. A test that passes against it passes for a reason.
  • Time is yours. Declared deadlines — a work descriptor's lifetime, a reservation, a retention window — are reached by advancing a step, not by sleeping.
  • Determinism is declared and bounded: order within a stream, the declared delivery mode, controllable time. Floating-point determinism is not promised, so a full simulation does not replay here either.

A mock checks that you called what you meant to call; this checks that what you called makes sense.

What you install, and the version floor

The core is one unit; optional modules are separate ones, each with a declared composition and a declared list of mandatory dependencies. Adding a unit never changes another's surface — a module mounts where its declaration says, so nothing appears or disappears elsewhere because of what you installed beside it.

If an optional unit is referenced but cannot load, that is a declared outcome of initialisation — the same place a disabled module is reported. Never a stub that silently does nothing.

Every binding declares the minimum runtime version it is built against. Below it, you get a refusal at initialisation, not partial operation: a runtime that is too old otherwise breaks at the first capability it lacks, which is somewhere arbitrary in your code and usually on a player's machine rather than yours. Raising that minimum is a breaking change and goes through the same process as any other.

Where to go next

Building blocks

Not translated yet — showing English.

Core

Core is the one object you create, and everything else hangs off it. One key in, and you hold context, identity, typed failures, tracing and batching. Every module call passes through it, and no module ships its own version.

Who addresses Core, the 4 things it provides, and the 1 module it builds on

When to use it

  • You need to know who and where you are — identity, roles, unlocked modules, project · env · region, all on the one object you hold.
  • A cloud function must write as a player — the write is attributed to that player, and the record names both parties, the function and the player.
  • Retries must never double-apply — batch operations carry an idempotency key.
  • A failure must be branchable and searchable — every throw is a typed Problem with a stable code.
  • Skip it when you are after messaging, calls or state — those are the primitives: Events, RPC, Data.

Who does what

ActorOn this page
any actorreads identity, context, roles via Whoami
backend-serviceacts as a player; batches idempotent operations
operatorreads traces for failed or retried calls

At a glance

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

Identity lives inside Core itself. No session or ctx parameter ever appears in a call.

The model

What every error carries.

FieldWhat it is
codethe machine-readable name of the refusal, and it is stable. The vocabulary is a projection of the platform's existing codes: a new code for a refusal the platform already names is forbidden
categorythe class the refusal falls into, which is what says whether a retry is meaningful at all
trace identifierthe id of this one occurrence, present always, local errors included, so contacting support never requires reproducing the fault first
explanationhuman text for a person to read, and it is not stable: titles and explanations change and are localised at any time
per-field errorsthe list a validation refusal carries — field, code, message for each rejected field

A consumer branches on the code and the category, never on human text — not by comparison, not by substring, not by parsing. An error from which only the message is reachable is a defect of the binding rather than a shape to work around.

Three origins, and they are not the same thing.

OriginWhat happened
platformit answered with a refusal, carrying a code from the platform's catalogue
localthe SDK refused before sending, from its own published vocabulary
unknownthe call was sent and no answer came back. Neither "the platform said no" nor "we never asked"

What is true of every refusal.

AlwaysWhat it is
a refused operation applied nothingatomicity is the platform's obligation, not yours: no compensating read on an ordinary error branch. Exactly two cases are excepted and both say so where they arise — a timeout, whose outcome is unknown, and a batch under per-element semantics
the delivery pathdoes not change the error: the same code, category and origin reach you whether the binding raises, returns a result value, or calls back on a subscription. A path that carries less than another is a defect of that binding
a timeoutis not an outcome: it is the third origin above, and what to do about it is declared per operation rather than guessed

Core carries the context, not the messages. Emitting and subscribing to facts is the Events primitive; calls — request/response, one-way, group fan-out — are the RPC primitive; state, subscriptions and streaming reads are the Data primitive, addressed through Entity. The audiences all three fan out to are the fourth primitive, Groups. Sized transfers (uploads, downloads) surface in Files & UGC. Mounting semantics — namespacing, collision rejection at mount time — live on Under the Hood.

Errors

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

What a caller without the role sees. The fn and adm rows are refused, not degraded: a player or client session that calls act as player, emits a trace or reads one gets a Problem with code forbidden — the credential is valid, the roles behind it carry no such right, and repeating the call with the same idempotency key changes nothing. There is no reduced variant that runs with fewer rights and returns less.

Every failure is a typed Problem with a stable code: the same codes the wire contract documents, so a client can branch on them and a human can search them.

Limits

A limit is applied at admission. A call that was accepted has already passed the limit and will be carried out, however long it waits to be processed; a refusal that falls on some later call does nothing to work already admitted. So a queue that filled up is a queue that runs — retrying the accepted call because a neighbour was refused is how you get the work done twice.

You learn a limit by being refused, and there is nothing else to read. The SDK surfaces neither the value in force, nor the headroom left, nor a warning that one is being approached, and nothing about a limit is ever put in front of a player. The refusal carries the whole of it:

  • the category, which is what says whether a retry is meaningful at all
  • whose limit it was
  • when a retry is allowed, and over what window

Branch on those. There is no counter to poll and no budget to display.

User flow

One failing call, from the throw to the trace an operator reads.

Building blocks

Not translated yet — showing English.

Events

An event is the fact that something happened, delivered to everyone who should hear it. Use it for what happens once and cannot be caught up from a current value — a shot, a purchase, a room joined.

Who addresses Events, the 5 things it provides, and the 1 module it builds on

When to use it

  • Something happened and others must react — a shot fired, a door locked, a match ended.
  • The audience varies — the same emit reaches a squad, a room, or one actor, decided by the target the type declares.
  • You want typed handlers with autocompletion — a declared event becomes send. and on. on its surface, each with its own contract.
  • The fact must still be readable an hour later — declare the type retained and read it back by period.

Who does what

ActorOn this page
schema-authordeclares events with [Event], pushes the schema
any actoremits via send., subscribes via on.

At a glance

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

An event declared inside a module or group surfaces only there: squad.send.rallyCall exists because rally_call is declared for squads, and the emit reaches squad members. An event a module emits outward is part of its declared contract; callers never learn about undeclared ones.

The model

What an event type declares.

DeclaresWhat it is
namean explicit wire name, declared rather than derived from the symbol
payloadthe schema of what an emission carries
targetwhere emissions of this type go: an entity instance, a group, a room or the global context. A group target is bulk delivery — one signal, many recipients. A changing addressee is expressed as a group, never as an address passed at the emit
clocksim_time or timestamp, never both — sim_time for facts inside a simulation, which take part in prediction, lag compensation and rollback; timestamp for facts outside it, like a purchase or a sign-in
retentiontransient — reaches whoever is subscribed at emit time and is not stored; or retained — stored and read back by type and period, not through the query surface Data carries. Declared, never inferred from the kind of event
termon a retained type: how long it is kept, and what happens at expiry. "Forever" is not one of the values
deliveryat most once, at least once or exactly once — declared on the type, so a subscriber never has to ask which one an emit used; "exactly once" states the bounds it holds within
contextthe context the type is declared in, global or local. A globally declared name is visible in local contexts; a locally declared one is not visible above. What a module emits is its contract either way — a caller never learns of an undeclared event

What an emission carries.

FieldWhat it is
typethe declared event. Two emissions never merge: two shots are two events, and the second does not absorb the first — which is what separates an event from the [Sync] field Data carries
payloadconforming to the type's schema
sourcethe emitting actor, plus its instance when an entity emitted it. An event emitted by a client is a claim, not a fact: the authoritative side checks it before anything depends on it
stampon the type's declared clock
dedup keypresent under every delivery mode, because redelivery is possible in all of them — a transport duplicate, a second read of a retained event
cause keyon an event the platform emits because of another platform event: the id of what it follows from, so a chain is reconstructed by key and never by comparing stamps

What a subscription holds.

HoldsWhat it is
eventthe declared type it is bound to
surfacethe node it is taken on, inside the type's declared target — the subscriber's half of the audience
handlertyped to the payload
positionwhere it resumes from, declared, so a reconnect does not silently restart at "now". What was missed in the gap is not replayed: a transient event is unrecoverable, and only a retained one can be read back

What is true of every event, whatever the type declares.

AlwaysWhat it is
audiencenever enumerated by the sender: it is the type's declared target narrowed to whoever is subscribed, then gated by Access — publishing and subscribing are separate rights and neither implies the other, and a stream may be closed by a predicate even where the type itself is visible. A sender that could list recipients would have to reproduce what Groups and Data already know
phasesemitted, then delivered — and nothing else. An event has no state machine: it happens once
orderingpromised within one stream, and for events a stream is one emitting instance: two events from the same instance arrive in emit order. Between streams no order is promised in any form — not between two instances, and not between a delta and an event about the same change
crossing streamswhen order across streams is needed the mechanism is declared, never assumed: bring the messages into one stream, or carry a causal stamp in the payload
gap detectionwhere the mode admits loss, the subscriber learns of the gap rather than silently skipping

Whether a fact is kept after delivery is a slot on its declaration, not a decision taken at the emit — so the same type is always kept the same way and no caller has to remember which call was which.

TransientRetained
Reacheswhoever is subscribed at that momentthat, and a subscriber arriving afterwards
Afterwardsgonekept for a declared term
Readable backnoyes, over the term
Past the term—a selection refuses, rather than answering empty
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

Errors

  • Publishing and subscribing are separate rights, and neither implies the other. A subscribe without the right answers forbidden, not not-found — the type is in the module's declared contract, so there is nothing to hide.
  • Subscribing to a type the module has not declared is a contract error, surfaced as a typed Problem — never a silent no-op.
  • At the emit, three validation refusals: an undeclared type, a payload that fails the schema, and a target the type does not allow.
  • A selection past a retained type's term refuses, rather than answering with an empty page.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Payload size — over the ceiling the publish fails and the event does not happen, never a truncated payload.
  • Publish rate per source — a rate-limit refusal carrying the moment to retry.
  • Subscriptions per actor — the new one is refused and the existing ones kept.
  • Retention volume per type — eviction by the declared policy, by term, never at random.

User flow

One rally call, from the declaration to the marker each squad member sees on their own screen.

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

Not translated yet — showing English.

RPC

A typed call whose body lives somewhere else. RPC is the second primitive: 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 the declaration names, not a second verb, and there is no do.

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

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 a timeout.
  • 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; an entity's RPC lives 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

ActorOn this page
schema-authordeclares RPCs, their modes and who may call them
any actorinvokes an answering or a one-way call, where the declaration allows it
group memberanswers a fan-out call; one answer travels back per member

At a glance

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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.

DeclaresWhat it is
namefrom the vocabulary of verbs
inputthe arguments the caller must choose
outputexactly one declared type. A shorter reply is a declared type of its own, never the same type with fields quietly left out — otherwise "not asked for", "the object is absent" and "hidden by the access mask" become one indistinguishable absence
reply modewith a reply — a value of the declared output type or a typed refusal; or one-way — no reply, and the caller learns only of a local send failure. One-way must not be used where the caller needs the outcome: an unknown outcome costs more than a known refusal
execution modeimmediate — the outcome returns within the call; or deferred — the call returns a work descriptor and the outcome is read or arrives by subscription. Declared, never picked by the implementation according to load, because the caller builds its behaviour on the shape of the reply
streamingwhether the input and the output arrive in parts and are handled as they arrive, rather than as a whole
idempotencya one-way RPC carries an idempotency key too: no reply does not mean no redelivery
overridabilitydeclared on the method itself. No declaration means not overridable — never overridable by default
contextwhere 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

What an invocation carries.

CarriesWhat it is
argumentsonly what the caller must choose
implicit contextthe 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
referencesan argument that is an SDK object travels as a typed Ref — an identifier or a cursor, never a copy of its content. The recipient resolves it on its own behalf, under the same permissions and predicates: a reference is an address, not a granted permission
outcomea value of the declared output type, or a typed Problem

What a deferred call's descriptor holds.

HoldsWhat it is
stateaccepted → running → completed or failed, the last two terminal
lifetimedeclared; past it the outcome is unavailable and asking for it is a refusal, not an empty answer
cancelidempotent, and honest: it asks, and the terminal state you observe is whichever of completed or failed the work reached

What is true of every RPC.

AlwaysWhat it is
one handlerexactly one logical handler — which is what separates an RPC from an event, where there may be none. So addressing a group is N calls and not one: Groups supplies the addresses, and the answers come back as a stream, each bound to the member who sent it
meaninga request to perform an action, where an event is an assertion of a fact. A one-way RPC and an event look alike from outside and are not the same thing: an RPC's handler is obliged to exist, an event may have no recipients at all and that is normal
no state machinea declaration has none, and an immediate call has none — it either returned an outcome or it did not, and then the timeout rules apply. Only a deferred call has observable states
a streamis not atomic: a streaming output promises nothing about the whole: a receiver has to be ready for an interruption and to tell "the stream completed" from "the stream was interrupted"
no predicate on a writeno 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, hand the list to a batch operation with declared partial-failure semantics. As the input to a write, a predicate is evaluated at a moment nobody named over a set nobody saw

Every RPC reaches its handler through the router, and which of its directions answers is declared per method rather than being a property of the call site — see Extensibility.

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

Errors

  • 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, and a caller without it gets a typed forbidden refusal, code and all — 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. That refusal is repeatable — a game server can come back — while "not found" would tell the caller to stop trying.
  • 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 is what makes the retry safe — including on a one-way call, where the absence of an answer is not an absence of redelivery.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Input size — the call is refused before execution.
  • Output size — refused rather than truncated, because a trimmed answer is indistinguishable 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 and the ones in flight finish.
  • Descriptor lifetime — past it the outcome is unavailable, and that is a refusal.
  • Call-chain depth — a declared refusal on exceeding it, never exhausted resources or a silent break.

User flow

One score submitted, one ping reported, one squad asked whether it is ready.

Building blocks

Not translated yet — showing English.

Data

You change one field. Everything downstream happens with not one line of code. Data is the third primitive: the mechanics under every synced field — deltas against the last acknowledged state, the aspect as the unit of policy, priority and send rate, resumable subscriptions, the retained window, and before/after change hooks.

You address entities, not tables — see Entity for the read and change surface (find, filter, sort, page, subscribe to a selection); this page is the mechanics underneath. There is no consumer path to a table, and no second way to write: a change is an Entity operation, and the delta is what follows from it.

Who addresses Data, the 5 things it provides, and the 1 module it builds on

When to use it

  • You need state replicated to clients without snapshot code — changing a field is the whole sync.
  • Fields differ in urgency or audience — priority and a send-rate ceiling per aspect, and a visibility predicate for fog-of-war.
  • A reconnecting client must not diverge in silence — a gap is detected and named, and a gap past the retained window is answered with full state.
  • You need the recent past — the retained window of deltas, indexed by sim_time, is what prediction and lag compensation read.
  • A validation rule belongs in one place — a before-change hook clamps or vetoes before the change lands.
  • Skip the knobs when all you need is to read or query — Entity's surface rides these mechanics without touching them.

Who does what

ActorOn this page
schema-authordeclares aspects, their sync policy and the visibility predicate
any actorsubscribes to a target; resumes from a position; requests full state
backend-servicebefore/after change hooks
operatorreads per-actor packet cost; sees when delivery degrades or a packet is cut

At a glance

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

The model

What a delta carries.

FieldWhat it is
changed fieldsonly those, never the whole object
pairthe instance × aspect it belongs to
numbera sequence number within that pair, which is what makes a gap detectable

What an aspect declares.

All of it by attribute, on the aspect or on a single field, never by a call at runtime. The aspect sets the default and a field may override it; the aspect stays the unit of policy, because otherwise there is nothing to assemble presets from.

DeclaresValues, and what it is not
priorityorders what is sent first when the channel is not enough. Not a promise of latency: it is relative, and it orders sending between fields rather than guaranteeing a delivery deadline
max update ratean upper bound on sending. Not a promise of receiving at that rate — receiving depends on the channel
delta onlydo not send what has not changed
delivery modeshared packet — the same thing to everyone, cheap on CPU; or per-actor packet — each their own by their visibility zone, dear on CPU and necessary at large populations
visibility rulethe predicate deciding who receives at all — Visibility projects that half in full
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

What a subscription holds.

HoldsWhat it is
targetan instance, a selection, or an aspect, and it receives that target's deltas. A target is not a stream: one target may cover many pairs, and ordering is promised inside a pair rather than across a target
positionwhere it resumes from: the consumer presents it. If the gap is larger than the retained window the full state arrives instead of a stream of deltas, so a long disconnect never leaves a client silently wrong
stateactive → gap detected → resynchronised | closed, and closed is terminal

What is true of every stream.

AlwaysWhat it is
mergingdeltas admit it: 100 → 90 → 80 between sends may arrive as 100 → 80, because the final state is still correct. That is exactly what separates a delta from an event, where losing one loses information for good
gap detectionsilently losing a delta is forbidden; the sequence number in the pair is what the consumer counts
orderingholds within one instance × aspect pair; between pairs it is not promised in any form
traversalis over declared things only: what may be a filter, a sort or an inclusion is a declared field and a declared reference. The selection surface of entity is the projection of that model, and this primitive gives the consumer no traversal of its own — there is no second query language
historyis built from deltas: an entity's instantaneous window is a retained window of deltas indexed by sim_time. Its depth is this primitive's limit, and it promises no reproducibility over floating-point fields
the packet budgetdegrades as declared: when the per-actor budget runs out the platform falls back to the shared packet as declared, rather than beginning to lose recipients arbitrarily

What a hook may do, and when.

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

HookWhat it may do
before a changemutate it, or veto it. A vetoed change produces no delta at all — subscribers see nothing, rather than a value and then a correction
after a changeadd side effects, and it can never fail the change

Deleting is hooked on Entity, where the delete lives; this primitive hooks the change.

Errors

  • An undeclared subscription target is a validation refusal.
  • No permission to subscribe answers forbidden or not found depending on whether the target's existence is itself a secret — the refusal must not become an oracle.
  • A resumption position that does not parse is a bad request, never a silent restart from now.
  • A subscription closed by the platform's side is a conflict, and it is observable: the machine's closed is terminal and reaching it is not something a client has to infer.
  • The subscription count exhausted is a conflict — the permission is held, the room is not.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • The delta retention window — resuming from older than the window gives the full state rather than a refusal.
  • Subscriptions per actor — a new one is refused and the existing ones continue.
  • Delta size — the delta is split rather than truncated, and the split is observable.
  • The send rate — an upper bound, not a guarantee.
  • The cost of a per-actor packet — on exhaustion, declared degradation to the shared packet.

User flow

One position change, from the assignment to corrected motion on every screen.

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Not translated yet — showing English.

Groups

One list, one mass listener. A group is the fourth primitive: a named set of actors that receives as one. You address the group and every member hears — a room, a chat, a matchmaking pool and a send-list are the same primitive with different rules: different entry and exit logic, different lifetime, the same list underneath.

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

When to use it

  • You need parties, squads or guilds — named sets of players with a declared capacity and, where the type declares one, a lifetime.
  • Membership should follow a declared rule the platform evaluates — new veterans fall in without a cron job, and without a re-evaluate call of your own.
  • You want to address many players at once: a declared event fans out with send.*, a declared RPC reaches every member and each answer comes back named.
  • You need one membership model reused as an audience — a visibility scope, a messaging conversation, a matchmaking party.
  • Skip creating one when the set is one session's members — rooms is this primitive with room rules, and already addresses those.

Who does what

ActorOn this page
playercreates groups from declared types, joins and leaves, adds or removes members, sends events, invokes fan-out RPCs; holding the administration right on a group's membership, removes members and closes it
room-ownerroom seat rules ride this primitive (configured in Rooms)
backend-servicedeclares group types and their rules; hooks on entry and exit

At a glance

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

A room's chat is this primitive with message semantics on top: the room declares its own group type, mounts it in the room's namespace, and lets the room's own membership decide who is in — so the chat's list and the room's list can never disagree.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

Nothing about a chat is in the primitive. The audience and the send.* surface come from here; author, thread and history come from Messaging, and who may administer the membership is a right of its own (Access), held by the room.

The model

What a group type declares.

DeclaresWhat it is
namethe type's own
membership modeexplicit — a member is added and removed by an action; or dynamic — membership is derived from a rule, and whoever satisfies the predicate is a member. A group is one of the two, never both
rulefor a dynamic group: the predicate, in the same predicate language as access predicates and transition guards. The platform recomputes it; nobody polls
capacityand the behaviour on reaching it
entry rulea predicate that may reject entry, separately from a hook that may also reject it
lifecycle behaviouron the first entry — created on first entry or created in advance; and on the last exit — closed on last exit or kept while empty. Declared, never inferred from observation
lifetimeoptional: once it expires the group closes with an event

What is true of every group.

AlwaysWhat it is
memberan actor, never an entity: a set of entities is a selection over Data. A group is one mass listener
statescreated → active → closed, and closed is terminal. A group instance has a machine; the type does not declare it
event targetemit on it and its members receive; that is what makes bulk delivery one signal rather than a loop
a group callis N calls, not one: the group supplies the addressing and each outcome arrives bound to the member it came from. RPC requires exactly one logical handler, so a broadcast awaiting many answers is N calls, not one
partial outcomenever reads as a complete one: a member that failed, timed out or refused is its own answer carrying its Problem beside the ones that answered; a partial success is never returned as a full one
recipientsare never enumerated by the sender: membership decides, so a sender does not have to know the composition of the audience
intra-group rolesdo not exist: a "group owner" is an actor holding a right (Access), not a rank stored in the member list
first entry and last exitare distinguishable from the joins and leaves in between — which is what the declared lifecycle behaviour and round initialisation hang on
recomputationcarries the delta: a rule-declared group's composition event says who came in and who dropped out, never the whole list. The whole list is a read, so a subscriber that only wants the change never pays for the roster
join and leaveare idempotent: a reconnecting client repeats its join, gets the same membership and no error — client code never has to tell "I am already in" from "I am not allowed in"
the interfaceis a concrete group's, not only the type's: you address this squad
the primitivestays empty: entry rules, round initialisation on the first member, event interception — those are the modules built on it. A room is a group with seat rules, a messaging conversation a group with delivery rules, a matchmaking pool a group the matcher drains, a send-list a group with no rules at all

Errors

  • A rule that says no and a hook that says no are different answers. A false entry rule reads "entry impossible"; a hook rejection carries the hook's own reason and code. An entry hook that cannot be reached refuses the entry — the check fails closed rather than waving the actor through.
  • Dynamic membership refuses hand edits. Add or Remove on a rule-declared group is a validation refusal: the predicate is the only thing that moves that list, and the platform recomputes it when the data behind it changes.
  • Four rights, none implying another — enter, administer membership, publish to the group, read the composition (Access). An actor missing one gets a refusal, never a silent no-op; a group hidden from it by a visibility predicate answers "not found" instead, and a member always sees its own membership even when the composition is closed to it.

Limits

  • Full is a conflict, not a matter of right. At capacity + 1 the entry is rejected as a conflict — the actor was allowed, the seat was not — and the same call succeeds once a seat frees. The capacity itself is on the type (Capacity = 4 above); how many groups a project and a single actor may hold is set with the platform limits chapter.
  • An oversized group call is refused whole, before anything is sent — a fan-out is never half-delivered, so no caller has to detect that case. The size ceiling lands with the limits chapter with the platform limits.

User flow

A party forms, one rally call reaches every member, one fan-out RPC brings back an answer per member, and the squad queues as a unit.

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Not translated yet — showing English.

Extensibility

Every platform scenario is a chain of registered functions. Replace a link or wrap it. This is what "customisable platform" means concretely, and it is what replaces open source: you replace the platform's own steps with your own, so you don't need our source.

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

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 scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

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. That is what turns "which of these runs first" from a fight over numbers into a statement about the work, and it is why ordering 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
a name registered twiceis a defect of the declaration, refused when the set is declared rather than resolved at call time
an unregistered nameanswers not found, rather than being silently dropped
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

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 consumer doing something nobody foresaw is what the mechanism is for, and a closed vocabulary would turn that into a registration-time rejection
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 timethat would be a second answer to a settled question, asked at the one moment nothing can be done about it
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 @na tabs on this page's samples mean by subscribing to the resulting events
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.

Take it, change it, ship it. "Proprietary open source" is a workflow, not a slogan — the platform's own logic is functions you can pull, edit and redeploy:

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

Customisation here runs on one axis, and it is logic: the overrides, middleware and versions this page is about.

The other axis does not exist. You cannot add your fields to a platform entity. A player, a room, a leaderboard record and an order are system state with their own machines, not the beginnings 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 — which is also what keeps it yours when the platform's own model moves.

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 resolved by a coin toss 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

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • 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.

User flow

One purchase, from the player's click through the customised chain to the squad ping. fraud-check is the studio's own handler on Before("grant"), not a platform module; Commerce draws the same purchase from its own side.

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

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.

Lessons & recipes: a leaderboard in Tanks uses this module's hooks; a daily tournament runs through this module.

Your game's model

Not translated yet — showing English.

Schema as Code

Declare the model in code, push it, get types back. The developer's way into the schema: the admin panel and code author the same model, and codegen closes the loop for every engine.

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

When to use it

  • Your data model should live in code and review like code — declare, schema diff, schema push.
  • Engine types must never drift from the deployed model — schema codegen regenerates the Unreal C++ and Unity C#.
  • A breaking change must be readable and cancellable before it runs — propose → plan → apply.
  • You reuse one bundle (Stat, Interactable) across projects — declare it once as a preset.
  • Skip it when an operator only tweaks values in the admin panel — the model change still diffs back into code.

Who does what

ActorOn this page
schema-authordeclares entities/parts/enums in code, diffs and pushes
operatorreviews the panel overview, proposes and applies migrations
cithe build pipeline, running under a backend-service key: pushes on a merge and regenerates engine types afterwards

At a glance

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # local declarations vs deployed schema
playserv schema push       # with a revision precondition — no blind overwrites
playserv schema codegen    # regenerate Unreal C++ / Unity C# types

Push is an explicit step. Nothing is uploaded when you save a file: a declaration reaches the deployed model only when playserv push (or schema push) runs, from your machine or from CI, and it carries the revision it was diffed against. A panel edit is visible to you the way any other drift is — schema diff shows it against your declarations. Seeing it is automatic; moving it in either direction is a command you run on purpose.

The model

What a declaration carries.

CarriesWhat it is
keythe stable name it is addressed by. A rename in code is a rename, not a delete-and-create
kindan entity, a part or an enum, declared in code
ownership modeseed — code creates the record if it is absent and a repeat push leaves the values alone, so the admin console owns them afterwards; or managed — code owns it always, every push brings values to what is declared, and edits from the admin console are refused rather than applied and lost on the next push
presetoptionally: a reusable bundle — an entity preset such as stats or world-objects — declared once and applied like a type. It introduces no new kind of declaration, and a preset that needed one would be a gap in the contract rather than a bigger preset

What a push promises.

AlwaysWhat it is
matchingis by key, never by symbol: a repeat push after renaming the symbol leaves one record rather than two
idempotencyfollows from that — a repeat push is not a second record
the reportsays exactly what will change before applying, and what it overwrote afterwards
originis distinguishable: a record created by a push from code is told apart from one created elsewhere
the revisionrides with it, and a push lands whole or not at all

What codegen promises.

AlwaysWhat it is
regenerationhappens after every push, and generated types are never hand-edited: regenerating and then diffing produces no change
namingfollows the declaration wherever it was authored — the Rarity field on Item becomes UPSItem::Rarity in Unreal and Item.Rarity in Unity
the two directionsdo not fork: whatever is declared in code appears in the panel, and whatever an operator authors in the panel diffs cleanly against code — which is what makes schema diff a complete answer rather than half of one

What a migration promises.

AlwaysWhat it is
when one is requireda persistent declaration change that rewrites existing values, and a breaking persistent change cannot be published without one
what it declaresa version, a preview, an ordered apply, a rollback on failure, and an observable completion outcome
coexistencewhile two data versions are live, reads and writes declare which versions they accept — the runtime never infers compatibility from field names

Errors

  • A caller without the role gets forbidden and the deployed model is untouched: a refusal is never a partial push. That is a different refusal from a stale revision, which is precondition_failed and means the diff was computed against a schema that has since moved — re-diff and push again.
  • A value outside a declared bound is refused on write, never clamped, and a string past its declared length likewise. Clamping produces a value that is valid and wrong, and the cost lands on support rather than on the caller: a refusal costs one round trip.
  • An invalid UTF-8 sequence is rejected on write rather than repaired.
  • A breaking persistent change with no declared migration cannot be published at all.

Limits

Declaration-shaped ceilings are checked at declaration time — on deploy or on publication — rather than at first use, wherever the runtime symptom would not look like a refusal. That is the rule the platform limits chapter states, and it is why a schema that ships is one that already fits. The numbers themselves land with that chapter.

User flow

One new field, from its declaration in code to regenerated engine types.

Your game's model

Not translated yet — showing English.

Entity

The module everything else leans on. An entity is a schema declaration grown with live aspects: data 0..*, states 0..*, RPC 0..*, events 0..*, hooks, and change history. Maps bind obstacles to entities, collision binds a transform aspect, stats is a preset, world objects are a preset plus a state machine.

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity mounts at the root, so room.Entity<Door>(id) and playserv.Entities<KeyDef>() sit directly on the root rather than behind a namespace. It builds on three primitives — events, RPC and data — and on nothing else. Collision, locomotion and prediction sit above it: each binds to one aspect, not to the whole entity, which is why a contact can fire a transition without the collision module knowing anything about permissions.

When to use it

  • A world object needs behaviour, not just fields — state machines, permissioned RPCs and events on one declaration.
  • Doors, traps, pickups: transitions must fire from client events, collision contacts, or stat thresholds without room code.
  • You want game objects as one-line creations — apply or derive presets like world-objects.
  • A dispute needs the exact world state at the shot — read an instance at a past sim_time, inside the declared window.
  • Skip it when the thing has no identity — a value that only ever lives inside something else, like the text on a door's plaque, is a field in an aspect, not an entity of its own. Everything that is addressed is an entity: Data is the mechanics underneath, and no table path bypasses them.

Who does what

ActorOn this page
schema-authordeclares entities, aspects, state machines, presets
every actorqueries, subscribes, calls entity RPCs, reads state

At a glance

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

An aspect is the declared unit, not the field: motion carries its own cadence and its own mask, info carries different ones, and a field belongs to exactly one of them. That is what lets a preset attach a whole group at once, and what lets collision bind to the one aspect that carries a transform without seeing anything else on the entity.

Three rules govern the machine in that block:

  • A dotted name nests one level. open.blocked associates with open on its own, so the close_requested transition declared on open applies inside it without being repeated. While the machine sits in open.blocked it is in open — a state check for open is true, and OnEntered("open") fired on the way in and does not fire again for the substate.
  • A timer declared on a state runs only while that state is current. Leaving opening drops its AfterSeconds, and entering it again starts a fresh one.
  • A guard is a declared predicate over the entity's own fields, in the same language as an access row predicate. Logic that needs code is a hook, not a guard.

Field paths are the pushed model's spelling. Predicates and query paths name fields as the declaration pushed them — motion.jammed, gate.state, info.name — whatever each binding spells them locally.

Who may call the RPC. An entity RPC names the right it needs the way every operation does: a right atom (entity × execute) plus a row predicate saying which instances it covers (Access owns both).

In the declarationWhat it means
caller in entity.roomany actor in the room the door is in, whatever build they run. Proximity is not part of it: how near you must stand to receive the door's deltas is a visibility rule on the aspect's sync policy — bandwidth, not permission, and widening a view never widens a right
Actorthe caller's identity, the same object whoami returns
caller.InventoryInventory's handle for that player, available wherever that module is mounted

playserv push is what makes a declaration real. Schema as Code owns the step: it diffs your declarations against the deployed model, carries the revision it was diffed against, and refuses instead of overwriting if the deployed schema has moved. A re-push that would break instances already live goes through propose → plan → apply, so the plan is readable before anything changes.

On the client, the entity is the API:

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

The locked signal is the door's own event: its target is the declaration's — this instance — so everyone subscribed to the door hears it and no recipient list travels with the send.

The model

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

A tank, a door, a stat bar and a quest are all entities. They differ in which aspects they carry and in nothing else, which is what lets every other module build on this one.

What an entity declares.

DeclaresWhat it is
aspecta named field group declared whole, with its own sync policy and access mask. An entity carries several, and no field is in two
state machinestates nestable one level, transitions and guards; several per entity
triggerwhat fires a transition — the four sources are below
entity RPCa verb sticking out of the entity, declared inside the view with the right atom it needs
entity eventa signal the entity emits, delivered to whoever subscribes to that instance
hookpre and post, on data operations and on transitions, deployed as cloud functions. Extensibility declares the order, the verdict shape and what a failure does
history trackwhether the view keeps the instant window at all
refa link to another entity holding its id and never its key, so renaming a key never breaks a link. Include pulls it in with the page

What fires a transition, and none of the four is your code running in a room.

SourceHow it fires
client event or RPCany declared one — RequestOpen above fires open_requested
collisiona contact or a trigger-volume entry — traps, pressure plates — through the aspect Collision binds to
data thresholddeclared on a stat, 0 HP → death, enforced by hook order rather than by code in a room
timeAfterSeconds on a state is a declared trigger, not a coroutine: it runs on the room's simulation clock, advances with sim_time, stops while the room is not simulating, and deleting the instance ends its machines and their pending timers with it

What a selection may do.

AxisWhat is admissible
filter and sortdeclared fields only — there is no table handle, and a selection is entity-addressed, scoped to a room or to the project
includea declared ref, pulled in with the page
pagingby opaque cursor: not an offset, not a row id, and its meaning does not survive a version change. Pass it back, never parse it
accesspredicates apply before paging, so a page never carries holes where hidden rows would be
livesubscribing to a selection keeps it live, with members entering and leaving as their data changes
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

What is true of every entity.

AlwaysWhat it is
a selectionis a set of entities, not a group: a group's members are actors and exist so one signal reaches all of them, while a selection is a read that happens to stay live
a transition requeststays a request: the machine's guards run, the row predicate runs, and a transition the machine does not declare is refused with invalid_state_transition rather than quietly ignored
pushing past a guardis a different operation with a different atom — entity × administer, which no client key holds by default
historyis the instant window only: recent states indexed by sim_time, bounded by a declared depth, and a read outside it is refused rather than answered with the nearest value. Branching history — alternate timelines, undo, replay of a whole match — is out of scope, because it would have to promise reproducible float values and the type rules do not
the boundary of a changeis one entity: two entities changed by one caller — debit a wallet, add the item — may be observed half-applied. A pair that must appear together therefore belongs in one instance, where the boundary does the work. A hook cannot stand in for it, because it runs around one change rather than across two
a declared method with no implementationis a finished state, not a half-configured one. Calling it answers with a verdict carrying the machine-readable reason "no implementation" — not a refusal, and not a success holding an empty result. A refusal would mean the call should not have been made; here it should have, and the only thing that did not happen is the decision
a name never declaredis a different outcome from a name declared without an implementation: the first is a validation refusal, the second a verdict, and code can tell them apart
an unimplemented calldoes not vanish — that someone invoked it is observable to the studio. Which form the observation takes is deliberately not part of the contract, so build on the fact that it is observable, not on a log line
creating an instancecarries the entity × write atom: a cloud function, a dedicated server and a master-client hold it by default, and a plain client only where a role grants it — in every binding, not only in Unreal

Presets. A preset is a named bundle of aspects, machines, hooks and limits applied to a view. It adds no new concepts — everything a preset brings you could declare by hand, which is why a preset that needs a new kind of declaration is a gap in the model rather than a bigger preset. Five ship: stats, abilities, projectiles, drops, world-objects, and Entity presets declares each in full. A studio derives its own from them, in code or in the panel — Crate is world-objects plus stats:

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Errors

  • An instance that does not exist, or one a predicate hides, both answer not found — so a refusal never tells a caller that something exists but is not theirs.
  • An undeclared field, nested ones included, and an obligatory field with no value, are validation refusals naming the field.
  • A transition the machine does not declare is refused as invalid_state_transition, never quietly ignored. Pushing a machine past its guards is a different operation with a different atom — administer, which no client key holds by default.
  • A version mismatch is a precondition failure: re-read and decide again.
  • A taken key is a conflict.
  • A write on a player's behalf that does not name the player is a validation refusal rather than a write attributed to nobody.
  • No permission answers forbidden, with read and write distinguished.
  • A history read outside the instant window is refused rather than answered with the nearest value — "no data for that tick" and "here is roughly the value" are different facts.
  • A stored instance past the size limit is a conflict naming the offending field and the measured size; the ceiling is reached by accumulation, so the approach to it is observable before the write that fails.

Limits

Every limit is declared together with what happens at its boundary. The numbers are per-project and set per project; the behaviour below is fixed now.

LimitAt the boundary
aspects per view · machines per view · nesting depth inside an aspectthe declaration is rejected at playserv push, never silently truncated
stored instance sizethe write is refused as a conflict, naming the field and the measured size; approaching the limit is observable before the refusal
selection page sizethe page is cut to the cap and "there is more" stays true — you never get a short page that looks final
instant-history windowa read outside the window is refused, not answered with the nearest value
change rate on one instancea rate-limit refusal carrying how long to wait

User flow

One door, from its declaration to the chime the player hears.

Your game's model

Not translated yet — showing English.

Inheritance & Composition

Modules build on each other, and none of it is subclassing. There is no base module to derive from and no hierarchy to extend — modules form a graph. This page is what "inheritance" means here, and the six mechanisms that do the work instead.

What inheritance means here

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

The word covers four different mechanisms, and they are worth naming apart.

  • An entity's RPCs are part of the entity. They exist nowhere else — not on some parent, not in a shared registry. If a method belongs to a door, it is on the door. See Entity.
  • A preset is a named bundle, not a base class. Stats, abilities, projectiles, drop generators and world objects are presets of entity — bundles of aspects an entity view applies, which is why they live on one page as Entity Presets rather than as five modules. Applying a preset adds aspects; it does not put your type underneath anything.
  • Overriding a platform step is an attribute on your replacement. You do not subclass ours; you declare yours, and versions are chosen by condition with the platform default as the fallback. See Extensibility.
  • A module borrows another through a decorator that narrows or enriches the borrowed interface, with the implementation swappable behind it. The worked case is a chat inside a room, on Groups.

There is no class hierarchy of modules, because a tree only allows branches and real features cross them. Matchmaking reserves seats in rooms; drops place items through the map; a leaderboard is fed by a hook on a room closing. That is a graph, and it is deliberate.

The six mechanisms

Each is declared with an attribute beside the thing it composes — the same declarative rule that governs everything else in the SDK.

Mount points, like a filesystem. A module mounts at the root — composing several interfaces into one surface — or into a namespace. A second module claiming an occupied mount point is rejected at mount time, never at the first call. Mechanism on Under the Hood.

Lexical visibility. Name visibility follows the nesting: a global declaration is visible inside a module, a local one never leaks upward. What a module emits is a separate question and is declared in its own contract — a module knows only the events it declared, or that were registered with it.

Encapsulation as a contract. A module never knows who calls it or why. What it exposes and what it emits is its entire public story, and nothing about the caller changes its behaviour except the caller's grant.

Reuse by decorator and inversion of control. A module refers to another through a decorator rather than by reaching into it, and the implementation behind the interface is swappable. This is the mechanism that lets you replace one of our modules with your own without the modules that depend on it noticing.

Declarations grow the API. Declare an event on a group and group.Send.ChatMessage(…) appears with its contract; declare data members and a typed getter appears. The declaration is the codegen input — which is also why the declaration, not the generated code, is the thing you version.

Three addressing axes out of one module. All instances, one instance, and the administrator of one instance are three distinct APIs, not one API with a flag. Stated in full on Groups.

Implicit arguments

Inside an entity you never pass the entity. The receiver, the caller and the ambient context bind automatically, because all three are already determined by where the call was made and who made it — passing them would restate something the platform already knows, and give you a chance to state it wrongly. The mechanism is on RPC.

Your game's model

Not translated yet — showing English.

Entity presets

A preset is a named bundle of entity aspects — data, states, RPC, events, hooks — packaged for one game case. You apply a preset, tune its numbers, or derive your own. Applying one adds aspects to your type; it does not put your type underneath anything — a preset is not a module and has nothing of its own to inherit from. Stats, abilities, projectiles, drop tables and world objects are five presets, not five subsystems: the same declaration, the same sync, the same hook order.

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

When to use it

  • A thing in your game carries numbers that clamp, regenerate, and fire a transition at their bounds.
  • An action needs cost, cooldown, phases and effects, reachable from one client verb.
  • Something goes in flight, and its hit must be judged fairly for a lagged shooter.
  • Loot must come from weighted odds that replay exactly when a player disputes a drop.
  • The map has furniture — doors, buttons, traps, destructibles — with states that must survive a mid-round join.
  • Skip presets when an entity is plain synced data. Declare the fields and stop.

Who does what

ActorOn this page
schema-authordeclares stats, abilities, projectiles, drop tables, world objects
room-ownertunes preset numbers, rolls drop tables, creates world objects
playercasts abilities, fires shots, picks up loot, interacts with objects

At a glance

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

The model

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

A preset introduces no new notions. Everything it adds is expressible by the means entity already gives — aspects, machines, hooks, persistence. A preset that needed a new kind of declaration would be a gap in the contract, not a reason to make the preset bigger. That is the whole test for whether something belongs here.

What each preset gives, and where it is tuned.

PresetWhat the contract gives itWhere it is tuned
statsan aspect of numeric characteristics with bounds, regeneration and modifiers, plus a hook on reaching a threshold — 0 HP becomes a machine transition rather than an if in your codethe field's declaration; the numbers stay live-editable in the panel
abilitiesan aspect of a set of abilities, a machine of application phases, and cost and cooldownthe ability's declaration
projectilesa type with runtime persistence, a ballistics aspect, and a hit eventthe projectile's declaration — one attribute swap changes the flight model
dropsan aspect of a drop table with weights, and a hook after deaththe table's entries and weights
world objectsa state machine of an interactive object, and an aspect of the interaction conditionthe preset's declaration, or per instance at creation
inventoryan owned type with a ref to a catalog item, a stack aspect with an increment, and a per-owner cap with declared overflowthe type's declaration

The table is one line per preset because one line is what differs between them. What they share is below, and inventory is the one that also has a page of its own.

What is true of every preset.

AlwaysWhat it is
where it sitson an entity, as aspects: its data syncs like any other data, its states are entity states, its RPCs are entity RPCs, and its hooks run in entity hook order
tuningis live config rather than a redeploy, which is why the panel shows a stat bound, a cooldown and a drop weight in one tree
declaring oneis a schema act, not a gameplay call — which is why its refusal is a different kind from the refusals a player meets, and both are in Errors below
deriving your ownis composition, not subclassing: Crate is world-objects plus stats, and the derived thing is still aspects on an entity

Errors

  • Declaring a stat, an ability, a projectile, a drop table or a world object is a schema act — fn or adm. A player or client key attempting one gets forbidden, and nothing is declared or half-declared. That is a different refusal from the ones a player meets inside a call they were allowed to make — on cooldown, cannot pay, a missing item:key.bronze — each of which carries its own code.

The rest of a preset's refusals are entity's — a preset introduces no notions, so it introduces no refusals either, and restating them here would give a reader two places to check for one answer. Two things are specific to presets themselves:

  • A partially filled preset is a validation failure at deploy. A preset carries a coherent set: half a declaration is refused before it ships rather than behaving oddly in a match.
  • A preset cannot be marked with a property its own mechanics contradict — an aspect the client has no rules for cannot be declared predictable, and that too is caught at deploy.

Limits

The ceilings are entity's — aspects per type, machines per type, the stored instance's size, the rate of changes to one instance. The one a preset declares itself is the per-owner cap an owned preset carries, with one of three boundary behaviours and no default: refuse · redirect into a declared owner bucket · discard with event. The numbers land with the platform limits chapter.

User flow

One shell, from the trigger pull to the crate at the shooter's feet. Four presets take part — ability, projectile, stat and drop-table — and none of them is a module you mount.

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Not translated yet — showing English.

Rooms

A room is a game session; the platform doesn't care what hosts it. One abstraction covers a dedicated server per match, one big shared map split into logical layers, a master-client-hosted room, and a backend-hosted mini-game. Room internals are ours; you drive a room from outside.

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

When to use it

  • Your game has sessions — matches, lobbies, dungeons, races — and something must own their lifecycle, membership and reconnects.
  • You host on dedicated servers, a player's master-client, or the backend itself, and need players routed there.
  • One shared map must run many logical sessions — layers, scoped by Visibility.
  • Players join mid-session and must see the current truth — the room's state on arrival, then live traffic.
  • A dropped connection must not cost the seat — the template's grace window (45s in battle) resumes the same membership.
  • Skip it when a feature is purely request/response over records — plain Data already covers it.

Who does what

ActorOn this page
room-ownerregisters rooms across the process; on one room instance — the per-instance admin interface: patches live config, kicks, locks, broadcasts, disposes
entry-validatoraccepts or rejects join requests with a code and a reason
room-visitorbrowses, joins with data, reconnects within grace window, leaves
spectatorjoins without contesting; receives broadcasts and live traffic
match-organizerreserves seats that count toward capacity; a reserve expires on the template's term (90s in battle)

At a glance

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Wherever it is authored, the pushed template is versioned and editable in the panel, so live-ops retunes a room type without an engine redeploy. Hosting rooms built from it is the same surface reached by a different role, and both an Unreal dedicated server and a master-client hold all of it — register, host several per process, patch live config, kick, publish, dispose.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Client:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

The model

A room is a group with rules, and it is not an entity. Membership comes from groups; its system state is platform-level, while the game's state lives in entities scoped to it. And no consumer code runs inside a room — in either authority mode.

What a room type declares.

DeclaresWhat it is
capacityin seats, and a seat is the unit of capacity separated from membership: it may be reserved before a join and held through inactivity. A reservation is time-limited, with a declared deadline after which the seat is freed without a join
visibilityenumerable · by name or code · hidden
creation modeone of three, and the on first join mode is obliged to declare an initialisation hook
two independent timeoutsthe idle timeout — how long a participant may be silent; and the empty-room TTL — how long a room with nobody in it survives. Two different questions, so two declarations
rejoin windowwithin it a return restores the same membership and the same seat, rather than making a new participant
authority modeour simulation or external authority, and there is no default
trust in a reported outcomefor external authority: accept it · check it with a hook · do not accept it. Again no default
behaviour when the host dropswait out the grace window · close the room · admit a replacement
map instance and world strataoptionally, which instance it occupies and which strata inside it
room-scoped entitieswhich of the studio's entities have the room's scope — expressed by a predicate, not by a new mechanism

The two machines.

OfStates
a roomcreated → open → closed → torn down, where torn down is terminal and closed means no new joins rather than gone
a membershipactive ⇄ inactive → departed, with departed terminal for that membership

What is true of every room.

AlwaysWhat it is
losing a connection and leavingare different events, and the window's outcome is observable: "returned" and "the window expired" are distinguishable, so a client is never left guessing which happened
no replaya reconnect resumes from the session state; the module does not promise the events of the gap
a spectatoris not a degenerate participant: present, occupying no seat, and not in the roster addressed as "the players" — otherwise every operation over the roster would carry a condition
no in-room rolesa room owner is an actor holding a right (Access), not a rank in the member list
presencehas a history, the roster does not: who joined, dropped, returned and departed is kept; the roster's changes are not a second history
the interfaceis a specific room's: you address this room, not only its type
three axes, not twothe API spanning every room (browse, register, list); the per-room API any member calls (join, leave); and a per-instance admin interface — kick, lock, patch config, close, dispose on this room — gated to whoever holds the admin or host role for that one instance rather than to membership
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

The two authority modes.

ModeWho runs the tick
our simulationour room implementation and its modules
external authoritya process running our SDK, in the room under an authoritative role: the studio's game server, or a player's client as the master-client

What the mode decides, and what it does not.

What it is
the lineis drawn by role, not by whose process it is. A dedicated server is the same client without the rendering; what separates it from a player's machine is trust, not construction — which is also why peer-to-peer needs no third mode, being a room in external-authority mode whose authority is a client host
what is identicalentry rules, presence, reconnect and every declaration, across all three. What differs is only which process holds the authority and how much of it that process is granted
the room does not moveno consumer code runs inside a room in either mode, and session and persistent state stay with us in both. A master-client is a member holding an authoritative role: the tick is computed there, the room does not live there
trust in the outcomeis a separate declaration on the room type — accept it · check it with a hook · do not accept it, and no default — rather than a property of the mode

Hosting a room.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
AlwaysWhat it is
the handleis the same object the client tab uses: there is no host bootstrap and no server-only handle. It answers these calls because the actor's role includes them at runtime — a dedicated server or a master-client running under a host key (Access)
registrationtakes an idempotency key, because a timeout on it is otherwise unrecoverable: repeat the call with the same key and you get the same room back, not a second one nobody knows the address of

Entry, presence and reconnect.

What it is
entryis a validated request: the joiner supplies data at join and the entry hook accepts or rejects with a code and a reason. That data is what the member initialises itself from — in battle the loadout carried in at join is what the member's Tank spawns with
seats and reservationsMatchmaking claims a seat for the template's reserve term — 90 seconds in battle — and the client then joins directly. Reservations count toward capacity, and expiry frees the seat with an event rather than silently
a drop is not a leavea disconnected member keeps their seat for the grace window (45s in battle) and reconnects into the same membership; expiry makes it a leave, and the event carries which of the two it was. Coming back one second late is told the room is alive and the membership is not — a different answer from "no such room", on purpose
late join is state, not a journala joiner gets the room's current state and then live traffic. Events sent while they were away are not replayed, and neither are the ones a returning member missed: anything that has to survive the gap is state — the mine a player laid is a room-scoped entity, not a MinePlaced message somebody has to catch

Errors

  • A room that does not exist or is hidden by a predicate, and a torn-down room, answer not found — a refusal never reveals a room you may not see.
  • Capacity exhausted is a conflict, and reserved seats count as occupied; worth repeating once a seat frees. Closed to joins is likewise a conflict, worth repeating if it opens.
  • A join rejected by a rule is a conflict; rejected by a hook carries the hook's own code and reason, so "a rule of the game said no" never arrives looking like a transport failure.
  • The rejoin window expired is a conflict: join as a new participant, with a new seat.
  • A lapsed reservation is a conflict: take a new one.
  • A map instance that is unavailable or does not exist is a validation refusal.
  • A move the target room rejected is a conflict, and what to do depends on its reason.
  • Creation beyond the room limit answers as a rate limit or a conflict depending on which limit it was.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • A room's capacity — a join is refused as a conflict, with reserved seats counted as occupied.
  • Rooms per project — creation is refused as a conflict.
  • Rooms per actor — creation is refused, and rooms already created are never torn down to make space.
  • The rate of room creation — a rate limit with a deadline.
  • A participant's idle timeout — a forced departure with an event and a declared reason.
  • The empty-room TTL — teardown with an event; switchable off on the persistent-zone type.
  • A seat reservation's deadline — release with an event.
  • Rooms on one map instance — creation on an occupied instance is refused unless the type declared shared occupancy.
  • The size of a room event's payload — publication is refused before sending, never truncated.

User flow

One match on a dedicated server, from the player's sign-in to the HUD showing who joined.

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

Not translated yet — showing English.

Who Sees What, and Which Machine Runs It

Two questions that sound like one. Who sees what is about a client: which slice of the room's state reaches which player. Which machine runs it is about a host: which process owns an entity, and which one owns it next. The word that runs them together is replication — in a game engine it usually names the first, and here it names the second.

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
You meanRead
which client receives which state, and how much of itVisibility, with Data and Prediction
which machine owns the entity, and what happens when it diesWhat Survives Losing a Host

They are declared in two different places

Neither is configured at runtime, and they do not share a declaration.

Declared onWhich names
who sees whatthe aspect — Data, Visibilitythe visibility predicate, the object cap and its order, which neighbouring areas are visible, and the delivery mode
which machine runs itthe room type — Roomsthe authority mode, how far an external authority is trusted about an outcome, and the behaviour when the host drops

The two also differ in what happens if you say nothing. An aspect with no visibility rule of its own is delivered in the shared packet, which is the default and is right for a small room. A room type that names no authority mode is refused — there is no default, because nothing can pick between our simulation and an external one on your behalf.

The live game

Not translated yet — showing English.

Visibility

At 40 players a whole-room snapshot is fine. At 200 it is not. A visibility zone decides who receives what, as a declared predicate rather than a switch you flip per object. Broadcast and per-actor packets are two declared delivery modes of one model, so moving between them is configuration rather than a rewrite. It is a channel optimisation and not a permission — for that, see Access.

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

One declared model — the predicate, the layers, the detail tiers — is read two ways. Moving between them is configuration, not a rewrite, because both are readings of the same declaration.

BroadcastPer-actor packets
Sendsthe whole room, to everyoneeach player only the slice their rules select
Suitsa small room; this is the defaulta crowd, where packet size must stay predictable
Reads the declarationonce, for the roomper actor

What must not leak is absent from the packet rather than hidden on the client — never sent, which makes it a security property and not a bandwidth one.

When to use it

  • Your rooms outgrow whole-room broadcast — 200 players need per-client neighbourhood streams, not every delta.
  • State must not leak: fog of war and owner-only fields should never be sent, not client-hidden.
  • Several sessions share one map and must not see each other — a layer is one more predicate.
  • The packet size must be predictable in a crowd — cap the objects and declare the order, so "the nearest N" is a promise rather than an accident of density.
  • A player at a boundary must see across it — declare which neighbouring areas are visible, because the default is only their own and a boundary otherwise reads as a wall of emptiness.
  • Skip it when the room is small — the shared-packet delivery mode already covers it.

Who does what

ActorOn this page
schema-authordeclares the visibility predicate, the object cap and its order, which neighbouring areas are visible, and the delivery mode
anysubscribes and receives what the zone admits; may lower the object cap for itself within the declared bounds

At a glance

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

The model

What a visibility rule declares.

DeclaresWhat it is
predicatethe rule itself, in the same predicate language as access predicates and transition guards. A radius, a map instance, a team and ownership are particular cases of a predicate, not separate mechanisms — there is exactly one such language in the contract
object cap and its ordera rule may cap the number of objects, and then the selection order is declared rather than inferred: "the nearest N" is a predicate plus ordering by distance plus a cap. A predicate alone cannot express it, because a predicate answers "does this row qualify", not "which of the qualifying ones is nearer"
neighbouring areaswhether objects from a neighbouring room or map instance are visible, and which ones exactly. Never implicit: with no declaration the area is the one the recipient is in
delivery modeshared packet — the same thing to everyone, cheap on CPU; or per-actor packet — each their own by their zone, dear on CPU and necessary at large populations

What is true of every zone.

AlwaysWhat it is
visibilityis not a permission: what the zone hides may be available by permission, and the reverse. The first is a channel optimisation, the second is security — and conflating them means a fog-of-war setting silently widens permissions, or ACLs get used to save bandwidth and rights start depending on distance
the recipientmay lower the cap: within the declared maximum and never below the declared minimum, because a predicate is the same for everyone whose context matched, while a packet size is the recipient's problem
truncationis observable: the recipient learns the packet was cut and by which order. Silent truncation is forbidden — it is indistinguishable from there being no more objects
degradationis declared: when the budget for assembling per-actor packets runs out the platform falls back to the shared packet as declared, rather than beginning to lose recipients arbitrarily: worse, but in a known way, instead of a leak indistinguishable from a game bug
packet shapeis a weak promise: the size and composition of a packet should not let the existence of hidden objects be inferred, and that is deliberately weaker than a MUST — fully hiding stream metadata at real volumes is unattainable. Where the leak of existence matters, use permissions, not the zone

Errors

  • An undeclared subscription target is a validation refusal.
  • No permission to subscribe answers forbidden or not found depending on whether the target's existence is a secret — the refusal itself must not leak what it is refusing.
  • A resumption position that does not parse is a bad request, not a silent restart from now.
  • A subscription the platform closed, and an exhausted subscription count, are both conflicts.
  • Widening a view is fn. Granting an override view and setting detail tiers are refused forbidden to a player session, and its view is unchanged: a spectator client cannot widen its own grant.
  • An instance the caller's view excludes answers not found, the same answer as one that does not exist — a forbidden would confirm that something stands behind the wall.
  • Reading per-actor packet cost is fn adm — a cloud function or the panel, never a client asking what it costs to be watched.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • The cost of a per-actor packet — on exhaustion, declared degradation to the shared packet with a notice, never arbitrary loss of recipients.
  • Objects per rule — capped with a declared order and an observable truncation flag.
  • Subscriptions per actor — a new one is refused and the existing ones continue.
  • Delta size — the delta is split rather than truncated, and the split is observable.
  • The send rate — an upper bound, not a guarantee.

User flow

A radius rule turns a 200-player room into per-client neighbourhood streams.

"Who sees this?" and "what do they see?" are both queryable, because the debugging session where you can't answer them is the expensive one. Per-actor packet cost is a first-class read, in code and in the panel.

The live game

Not translated yet — showing English.

What survives losing a host

A host dies mid-match. The match does not. This page is about the second meaning of the word "replication" — which machine owns an entity, and which machine owns it next. The first meaning, which client receives which state, is Visibility with Data and Prediction. Who Sees What, and Which Machine Runs It is where the two are told apart.

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

Room state is not copied between hosts

An entity has exactly one owner at a time, and no second machine keeps a live copy ready to take over.

Two copies accepting the same shot would have to agree on the order the two shots landed in. Agreeing on an order thirty times a second, between machines, is consensus — and consensus puts latency exactly where a game will not tolerate it. A single owner does not have that problem, and every mechanism below exists to make a single owner survivable.

What is replicated is presence: which actor is on which node. That is a small, slow-changing fact, so routing can know it everywhere without paying for agreement on anything that moves.

Declared state is held outside the host

Declared state is not private to the process holding it. It is snapshotted on a declared interval, so a replacement can resume from the last snapshot when the previous host stops answering, and the player re-enters through the ordinary rooms grace window.

Three things follow:

  • The replacement has the state whole, but as of the snapshot. Complete, not current. What a failover costs is the play between the last snapshot and the loss, and the interval is what fixes that worst case.
  • Tick continuity is not carried across a change of authority. A move the platform performs preserves the participant's tick state; a replacement of the authority does not promise it. Rooms is where both are declared, along with what happens when the grace window passes.
  • Anything you kept only in engine actors goes with the process. It was never declared, so nothing outside that host ever had it.

A deploy is the same path, without the loss

Draining a host — stop placing new rooms there, let the sessions in flight finish or hand over, then let it go — is the failover path run on purpose and with notice. That is why deploying without killing live sessions is not a second mechanism to build and trust.

The room's host learns of it the same way it learns anything: the platform gives advance notice that a room is to be closed or handed over for a reason on its own side.

What happens once the window passes is declared, and there is no default. A room type whose authority lives outside the platform names one of three outcomes for losing it — wait out a declared window, close the room, or admit a replacement authority. Leaving it unsaid is not an option the declaration offers: the alternative is a room with a dead authority that still accepts joins and holds seats, showing every participant a live session in which nothing happens.

Which machine is not part of your surface

You never name a node. Whoever creates a room does not choose where it runs, and no operation takes a host as an argument — placement is the platform's, and it stays the platform's so that it can move a room without your code being written against where it used to be.

If you host rooms yourself — a dedicated server or a master client — the same is true with one addition: you are told to wind down, and finishing or handing over your sessions inside the grace window is yours. Rooms is where a host registers for that binding, and Authority is why the host holds only the rights it was granted.

The live game

Not translated yet — showing English.

Matchmaking

Get a player into the right room. Tickets describe the player and filter the others. The matchmaker resolves a placement, reserves a seat, and game traffic then flows directly to the room.

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

The matchmaker is in the path once, to decide where you belong. It is not in the path of the match: its outcome is a placement and a time-limited seat reservation, and from the join onward the game traffic goes straight to the room. A busy queue therefore never becomes a busy game.

When to use it

  • You need players routed into rooms by declared criteria — mode, region, rank — not a hand-rolled lobby list.
  • Match criteria must come from platform data, not the client's claim: stamp rank in the pre-enqueue hook.
  • Queues should widen over time server-side while the client holds one ticket and never polls.
  • Parties must land in one match together — a group enters whole or not at all.
  • You run an external matchmaker and only need its decision terminated in placement + seat reservation.
  • Skip it when players pick a session themselves — the rooms browser and Join already cover it.

Who does what

ActorOn this page
playercreates and cancels their own ticket, and enters as part of a party
match-organizerdeclares matchmaker queues and relaxation; reads placement results
backend-servicestamps trusted criteria pre-enqueue; runs external matchmaker decisions

At a glance

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

Criteria the client must not be trusted with are stamped in the pre-enqueue hook:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

The read is the owner-list read from Leaderboards — the same one a friends cohort uses — and every row it returns carries that owner's rank in the full table. The hook may ask for somebody else's row because the board's predicate lets a cloud function do it; a player session asking the same question gets only its own.

The model

What a ticket carries, and the two parts do not reduce to one another.

PartWhat it isWho believes it
self-descriptionthe participant's declared properties — rating, mode, language, chosen mapnobody without a check: it is the caller's claim
requirementa predicate the rest must satisfythe platform, because it is the one that applies it

A ticket's participant is an actor or a group — a group enters whole, and that is what a party is. Its ticket is indivisible: the group enters a roster entire or not at all, because splitting a group would be a different promise and there is none.

What a queue type declares.

DeclaresWhat it is
propertiesby name and type. A property not declared here is refused in a ticket as a validation failure rather than ignored
roster sizea minimum, a maximum, and a compatibility step — the multiple at which a roster is acceptable, so teams "of five" means five, not any number between two and ten
requirement ladderan ordered set of predicates with windows: each rung is a wider requirement and a time after which matchmaking moves on. Relaxation is a declaration, never arbitrary logic in a handler
the predicate languagethe same one everything else uses, and its vocabulary includes the ticket's own properties — "a rating within ±100 of mine" is expressible. Without that the two-sided model does not work at all, because relative conditions are its whole point
mutualitywhether a roster is acceptable in which A accepts B while B does not accept A. There is no default
ticket lifetimeafter which the ticket transitions to expired with an event
outcomeRoomPlacement — a room reference plus reservations in it, for a simultaneous game; or RosterSet — the roster alone, with no room and no reservations, for an asynchronous one where the opponent is offline

A ticket's states. created → queued → matched · cancelled · expired, the last three terminal.

AlwaysWhat it is
one live ticket per participant per queuea second is a conflict, not a second application — read the existing one
the reason for a pairingis observable: it reaches the matchmaking event and the history. For the shipped algorithms that is the ladder rung; an overriding implementation may have no rungs, and then the reason is an opaque value it declares — but there is always one
expiryis an outcome, not an error: "a roster did not come together in the declared time" is a normal completion delivered as the ticket's outcome
the outcomearrives by subscription: not by polling. Matchmaking takes seconds and tens of seconds, so polling would turn waiting into load that grows with the queue's length — the client holds one ticket and never asks again
losing the connection cancels the ticketdeclared rather than inferred: a ticket is an application to play now, and matching an absent player makes the roster worse for everyone else
matchedis atomic: for RoomPlacement, either the roster is matched and every participant holds a reservation, or the tickets stay in the queue. For RosterSet the atomic result is the roster alone

Errors

  • A second ticket in the same queue is a conflict; do not repeat it, read the existing ticket.
  • An undeclared property, or a requirement naming one, is a validation failure — not a silent ignore that would surface later as "no opponents were found".
  • The queue is suspended answers unavailable, not forbidden: the caller's rights are intact and the situation is temporary, so retrying with backoff is right.
  • The room for the result cannot be created is likewise unavailable, with backoff.
  • The reservation failed is a conflict worth retrying — the ticket stays in the queue.
  • A ticket that is not found or is another's, and an actor a predicate does not admit to the queue, both answer not found, so a refusal reveals neither the ticket nor the queue.
  • "A roster did not come together" is never an error — see expiry above.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Tickets in a queue — creation is refused as a conflict, and existing tickets are not evicted to make room.
  • A ticket's lifetime — a transition to expired with an event.
  • Ladder rungs — a declaration with too many is refused at declaration time.
  • Group size in a ticket — the ticket is refused as a validation failure.
  • Declared properties per queue type — refused at declaration time.
  • Ticket-creation rate — a rate-limit refusal with a deadline.
  • Retention of matchmaking history — past the period an entry is unreadable by the declared period.

User flow

From sign-in to standing in the match room, with the rank stamped server-side. The journey starts at Auth because a ticket has an owner: without a session there is nobody to enqueue.

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Not translated yet — showing English.

Map

The static world: bounds, terrain, obstacles, and "where can things go?". The physical model is deliberately far simpler than the visual one: primitives with a footprint and a height, layers with rules, and one valid-position query every other module reuses.

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

When to use it

  • You need a static world — bounds, terrain, obstacles — the server can query, not just render.
  • Spawns, drops and decorations must land in legal spots: one rule-based RandomPosition query, no bypass.
  • Arenas should regenerate per match — a declared Seed reproduces the same map in a bug report.
  • Crates and walls break and come back — destructibles with HP and respawn timers.
  • Bots and projectiles need raycast and line-of-sight answers against the obstacle set.
  • Skip it when the world is purely visual and no server code asks where things can go.

Who does what

ActorOn this page
schema-authordeclares maps, obstacle primitives, destructibles, layers and their rules
room-ownerbinds a map to a room; requests spawn positions; raycasts
operatorplaces or removes obstacles and layers from the panel

At a glance

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter and Destructible are placement generators, not runtime rolls. A generator resolves when the map version is published: the forty rocks become forty declared primitives, and the published version carries the primitives, not the rule. The same Seed therefore gives the same forty rocks in the match, in the replay and in the bug report — and the geometry limits are checked once, on that resolved set, before the version reaches an environment.

The query everything else asks:

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

The model

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

Two layers, declared by different people.

LayerWhat it holds, and who declares it
staticterrain with height, obstacle primitives, bounds and locations — authored content
dynamicobstacles brought by entities at runtime: doors, destructibles, platforms. A destructible is therefore an entity with a lifecycle, states and an owner, and it becomes map only in the part where it brings an obstacle — a static rock is declared in the map, a door is an entity bringing one. These have no lifecycle of their own here: it belongs to the entity

What a map declares.

DeclaresWhat it is
key and versiona map is authored content: declared in code, addressed by key, and versioned — the version is part of what a room references. Changing the geometry of a released version is forbidden; an edit is a new version
terraina height field — a regular grid with a declared step, and the step is a declared precision limit, so a height query answers by it rather than exactly. Terrain may be absent: an arena in the void is lawful
obstaclesa closed set of primitives — box, sphere, capsule, convex hull with a declared vertex limit. An arbitrary triangle mesh is not accepted, which is the condition of a server-side check being possible at all
passability kind per obstacleimpassable · passable for a declared class · blocks the line of sight only. One primitive serves as a wall and as a bush, and the difference is declared rather than modelled twice
boundsthe volume outside which a position is inadmissible
world stratadeclared spatial strata inside a map — ground, underground, air. These are geometry and addressing declarations
locationsnamed places or areas — a spawn point, a capture zone, a corridor. A location answers where, never what happens: it carries no game logic
placement generatoroptionally, a rule producing primitives — a count, a minimum spacing, an area, a seed. It is resolved when a version is published, deterministically by the seed, and thereafter the map holds primitives rather than a rule

A world stratum and a map instance are never aliases.

What it is
world stratuma declaration inside the map — ground, underground, air
map instancean independent runtime copy of the published map. Instances share the immutable published geometry and have independent dynamic obstacles and independent entity rosters. A room occupies an instance and may select strata inside it

What is true of every query.

AlwaysWhat it is
one geometric canonall geometry is in the platform's declared coordinate canon, and the precision of every geometric field is declared on the field
the world modelis a simplification: the server's geometry is not the art model, and it is not obliged to be
an answer names its instance and its momenta query is answered from the map's static layer plus the dynamic obstacles of the instance it asked about, and it declares the moment it is true for — dynamic obstacles change, so the answer is a snapshot
the valuesare managed, not seeded: geometry is not a designer's daily tuning: an edit from the admin console is refused rather than silently kept
movement and contactare not resolved here: it answers what the space is; whether a position is admissible and what the response is belongs to collision, and applying it to locomotion

Errors

  • A map, a version or an instance that is not found answers not found, and so does a withdrawn version — repeating is pointless.
  • Publishing changed geometry under an existing version is a conflict: make a new version.
  • Publication failures land at declaration, at deploy, never at runtime — a map beyond the primitive limit, a convex hull beyond its vertex limit, and an arbitrary mesh as an obstacle are all validation refusals before anything ships.
  • A height query outside the bounds is not an error — it is the declared answer "outside the bounds", and it is distinguishable from "inside an obstacle", because a client turns round in one case and goes round in the other.
  • The query rate exceeded answers in the rate-limit category with a deadline.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Obstacle primitives per map, convex-hull vertices, height-field resolution, the size of the bounds, locations per map — every one of these is refused at publication, not at query time: a map that ships is a map that already fits.
  • Map instances per map — creating another is refused as a conflict; existing instances are never released to make room.
  • Versions kept — the oldest deprecating version is withdrawn, and a version under a live room never is.
  • The rate of queries to space — a rate limit with a deadline.

User flow

A scheduled airdrop asks the map for a legal spot, and a player drives over to collect it. The drop-table is an entity preset — a declaration on an entity, not a module you mount.

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Not translated yet — showing English.

Collision

Bind a transform to the obstacle map; declare what contact does. Collision runs inside the platform's simulation. You declare bodies, layers and responses, and subscribe to contacts.

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

When to use it

  • Moving entities must resolve contacts server-side — slide, stop, bounce — without a hand-written deflection routine.
  • Gameplay reacts to touch: pickups collect on overlap, trigger volumes fire an entity state machine.
  • Locomotion and projectiles need swept resolution against the map's obstacle set.
  • Placement previews or targeting need "would this fit here?" and volume-overlap queries.
  • Skip it when nothing physically meets — request/response gameplay over records is plain Data.

Who does what

ActorOn this page
room-ownerdeclares bodies, layers and responses; queries overlaps and contacts

Which rooms this applies to. This module runs where the platform steps the simulation — rooms declared Host = "Backend". If your own game server owns the simulation (PlayServ as the metaserver), movement, collision and prediction stay engine-side, and this page describes the platform-hosted alternative rather than a requirement.

At a glance

Shape, layer and what contact does all sit on the body itself — nothing declares layer pairs from a distance:

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

A contact is an event, and modules subscribe to it. There is no hook on a contact: by the time one exists the step has already resolved it, so there is nothing left to reject. Where a studio needs different rules, it overrides the admissibility and path checks as an implementation — see Extensibility — and the response stays declared.

The model

What a body declares.

DeclaresWhat it is
shapea primitive from a closed set — sphere, capsule, box — with declared dimensions. An arbitrary mesh is not on offer, the same constraint and the same reason as the server's world model in map
where it liveson an aspect, together with the transform: that is the unit of policy, and a body shares one fate with its transform
how its path is checkedstepwise — the step's final position is checked, fast, and a fast body passes through a thin obstacle; or swept — the segment between positions is checked, dearer, and tunnelling is excluded within a step. Declared, never chosen by an implementation from the speed: whether a projectile can fly through a wall is a property of the game, not an optimisation
areas it participates inwhich volumes it is counted inside
its relation to the art modelnone is required: a body is a simplification, and divergence from the art model is admissible within declared bounds

The response is declared on a pair — an obstacle's passability kind × a body type — and it comes from a closed set:

ResponseWhat it means
stopmovement ceases at the last admissible position
slidemovement continues along the obstacle by whatever component is admissible
bouncethe direction is reflected and the speed multiplied by a declared coefficient
dampmovement continues with the speed multiplied by a declared fraction
passthe obstacle does not affect movement, but the contact is still observable
cease to existthe entity ends — a projectile against a wall

The coefficients are declared values rather than computed from masses and materials — there are neither in this contract.

What is true of every check.

AlwaysWhat it is
every pairhas a response: a missing pair is a declaration defect, refused at deploy rather than met in combat
the response tableis readable by the client: the same table the authority computes by, so a client and a server given one declaration answer one contact the same way
reproducible within one authority, not across platformsthe same input in the same order gives the same result within one process and one build. Bit-identical results on different platforms and builds are not promised, and a network model built on the assumption that collisions compute identically everywhere is built on sand
simultaneityis declared: when two moving bodies collide inside one step the order of resolution is declared and deterministic. The storage traversal order, the order input arrived and randomness may not be the ground for it
one contact, one facta contact between two bodies is observable by both sides as a single fact with a single identifier, not as two independent events
extension points sit on the step, not on a contactbefore the step the transform may be changed, after it there is observation. A contact has already happened, so there is nothing to reject; different rules are a declared override of the admissibility and path checks, and such an override is obliged to be available to the client too
the modulemoves nothing itself: it answers whether a position is admissible and what the response is; applying that is locomotion's
a contact is an eventwhich is why modules subscribe rather than couple: a trap's state machine binds a transition to a trigger-volume entry, drops collects on overlap, and projectiles resolve hits through this module's sweep

Errors

  • "Inadmissible" is an answer, not an error, and it names which of three reasons: outside the bounds, occupied by a static obstacle, or occupied by another entity's body. A client reacts to the three differently — turn round, go round, or wait — so collapsing them into "no" would cost behaviour.
  • Declaration failures land at deploy, never on the first contact: a body shaped outside the closed set, a body on an aspect with no transform, and a pair with no declared response are all refused at deploy. A collision happens in combat, and a runtime failure there is observed as a wall that vanished.
  • The entity or the location is not found answers not found, and repeating is pointless.
  • The check rate exceeded answers in the rate-limit category, with the deadline before which a retry is pointless.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Bodies in a room — declaring another is refused as a conflict; existing bodies are never removed to make room.
  • Contacts per step — the excess is never discarded silently: either the step is refused, or the order of cut-off is declared.
  • A body's size against the map's grid step — refused at deploy, because a body smaller than the height field's step falls through the terrain, and that cannot be a runtime surprise.
  • Areas one body may be inside — an excess is refused at deploy.
  • The check rate per actor — a rate limit with a deadline.
  • Bodies in a "who is in this area" answer — truncated by a declared order, and the truncation flag is obligatory.

User flow

A trigger volume, a state machine and a door: contact events do all the wiring. The plate and the door are world objects — entity presets, not modules you mount.

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Not translated yet — showing English.

Locomotion

You declare how a thing moves; nobody writes an integrator. A movement model turns sequenced input into authoritative motion, integrated with collision, recorded for prediction, and modified by buffs, debuffs and terrain.

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

When to use it

  • Entities move under player input — tanks, characters, vehicles — and motion must be server-authoritative.
  • You would rather declare speed, acceleration and turn-rate limits than write an integrator.
  • Gameplay pushes bodies around: Impulse knockbacks, Teleport, and mud-style modifiers with durations.
  • Movement must feel instant: the same declared model steps on the server and in prediction's loop.
  • Skip it when positions change only in discrete steps — a synced field on the entity already covers it.

Who does what

ActorOn this page
schema-authordeclares movement models, constraints and bindings
room-ownerapplies impulse, teleport and modifiers from the host
playersubmits sequenced input; reads motion state

Which rooms this applies to. This module runs where the platform steps the simulation — rooms declared Host = "Backend". If your own game server owns the simulation (PlayServ as the metaserver), movement, collision and prediction stay engine-side, and this page describes the platform-hosted alternative rather than a requirement.

At a glance

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

Client input is a sequenced intent. The platform steps the motion:

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

Server-side verbs:

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

The model

What an entity declares in order to move.

DeclaresWhat it is
movement modelone of the shipped set — steering, tank, character, vehicle, flying — as several implementations of one step, with declared conditions of choice and a default. The step is a pure (pose, input, dt) → pose function
parametersdeclared values the client can read; without them prediction diverges systematically. They are a seed — a designer tunes them and a deploy must not silently lose the edits — while the limits anti-cheat rests on may be managed, and then an edit from the admin console is refused
limitsmaximum speed, acceleration and braking, maximum turn per input, the reverse multiplier, and a separate rotation speed for parts. The turn per input is declared apart from the rotation speed on purpose: one bounds an instantaneous jump, the other a continuous rate, and they are different defences
behaviour on stale inputstop, continue until a declared deadline, or continue indefinitely. There is no "carry on as before" default — a player whose network dropped would drive on
pose tolerancehow far a claimed pose may sit from the server's, and it may differ by state: standing, moving, and just after a respawn are three different tolerances
step rate and catch-up caphow often the step runs, and how many steps may be taken in one go when the server is behind
impulse kindseach with its magnitude and its manner of decay

What is true of every step.

AlwaysWhat it is
the module ownsan entity's position and orientation over time, and nothing else. The history of those positions is kept by entity rather than here, so "where was the player 300 ms ago" has exactly one answer instead of two buffers with different periods
authorityis the server's: under the our simulation mode a client sends an intent, never a result
collisionsare not resolved here: it asks collision whether a position is admissible and what the response is, and keeps no response table of its own
inputis an intent: "forward", "right", "turn the turret there" — accepted as it comes, because it asserts nothing about the world
a claimed poseis a claim: never a fact. Outside the declared tolerance it is clamped to the nearest admissible pose, and that produces an observable pose_clamped
input sequencingis required: the same sequence number is never applied twice, and a lower one is discarded
a limitclamps, it does not refuse: "ten metres forward this tick" becomes what is admissible rather than an error. That is what makes a limit anti-cheat by construction — the server physically cannot produce the illegal pose — and it is why the client is not flooded with refusals every frame
an impulse obeys the same constraintsrecoil, a shove, an explosion and knockback arrive from outside input, and none of them bypasses collision: recoil does not drive a tank into a rock
identical rules, not identical bitsno bit-identical result across platforms is promised. What is promised is the same rules, and reproducibility within one authority
the stepis pure and tick-driven: the same code steps motion on the server and inside the client's prediction loop, which is what makes reconciliation exact

Errors

  • A missing movement model on the entity, a partially filled preset, and an impulse with no declared decay are all validation failures at deploy, not at runtime — an open-ended impulse is a declaration defect, so it never reaches a player.
  • The expected generation did not match is a precondition failure, worth repeating after re-reading: input sent before a respawn must not be applied after it.
  • The input rate exceeded answers in the rate-limit category with a deadline.
  • The entity is not controllable is a conflict, and repeating it makes sense only after the state changes.
  • Three things are refusals in neither direction, and all three are observable. Stale input is discarded, an intent beyond a limit is clamped, and a pose beyond the tolerance is clamped as pose_clamped. Doing any of them silently would leave the client believing it applied and diverging from the server for good.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Maximum speed and acceleration — clamped, never refused.
  • Maximum turn per input — clamped.
  • Input rate per actor — a rate limit with a deadline.
  • Catch-up steps — beyond the cap steps are discarded with a declared consequence: simulation time falls behind and that is observable, rather than caught up in a jump that reads as everyone teleporting at once.
  • Impulse magnitude — clamped to the declared maximum.
  • Simultaneous impulses per entity — a new one evicts the oldest, and the eviction is observable; there is no silent unbounded summation.
  • The lifetime of a claimed pose — one older than the declared period is not considered.

User flow

The journey of one knockback: the player drives, the attacker in the other tank fires, and the impulse lands as a reconciled pose on the victim's screen. The ability and the projectile are entity presets — declarations on entities, not modules you mount.

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Not translated yet — showing English.

Prediction & Lag Compensation

The player pressed jump 50 ms ago. The packet only arrived now. They didn't fall. Forward prediction and backward compensation over data that carries its true event time: the client feels instant, the server stays right, and hits are judged in the shooter's timeline.

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

When to use it

  • Input must feel instant under latency while the server stays authoritative — predict forward, reconcile on divergence.
  • Hits must be judged in the shooter's timeline: ResolveAt rewinds hitboxes to the reported view tick.
  • Aim arcs and landing markers must match outcomes — client and server forecast the same Trajectory.
  • Game-critical fields must never roll back — declare what predicts and what waits for the server.
  • Rubber-banding needs tuning: per-entity windows, tolerances, and misprediction telemetry.
  • Skip it when latency doesn't hurt — turn-based or slow games run fine on plain Data deltas.

Who does what

ActorOn this page
schema-authordeclares predicted vs authoritative-only fields; sets prediction window
room-ownerresolves hits at a historical state; rewinds the world
playerpredicts and reconciles motion; subscribes to corrections

Which rooms this applies to. This module runs where the platform steps the simulation — rooms declared Host = "Backend". If your own game server owns the simulation (PlayServ as the metaserver), movement, collision and prediction stay engine-side, and this page describes the platform-hosted alternative rather than a requirement.

At a glance

The word covers three different things, they must not be merged, and each has its own article. They have different authorities and different failure modes — one word for all three means tuning one silently changes the other two.

MechanismWhat it doesRuns onWhen it is wrong
Predicting your own motionapplies the declared model to your own input without waiting for the serverthe clienta correction, replayed and smoothed
Showing other playersdraws other entities between the states that arrivethe clienta visible jerk
Lag compensationrewinds targets to the moment the shooter sawthe serversomebody dies unfairly

This page is the hub: the shared model, the shared declarations, and the presets that pick a combination for you. The three articles are where each mechanism is actually explained.

Four presets, and "no prediction" is one of them.

PresetPredicts your ownCompensatesSmooths others
shooteryesin a window of roughly a second and a halfyes
arcadeyesnoyes
observernonoyes
no predictionnonono — state arrives from the authority with a declared interpolation window

The last one is not a stub. Turn-based games, strategies and most mobile titles want no prediction at all, and a declared "we do not predict" tells the client to show state as it is rather than guess.

None of it applies under external authority. All three mechanisms exist for rooms our simulation runs. When a studio's game server or a master-client owns the tick, prediction is the business of whoever runs it — see Who runs the tick.

The module owns no movement model, no reaction table, no geometry and no history window of its own. Those belong to Locomotion, Collision, Map and Entity respectively. Prediction applies them earlier or reads them backwards; it never declares a second copy.

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

Lag-compensated resolution answers "where was everyone when this shot was fired":

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Trajectory forecast, shared by server and client (aim arcs, landing markers). The forecast is this module's operation, extrapolated against the map's obstacle set, so both sides draw the same arc from the same inputs:

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

The model

Predictability is declared on an aspect — and an aspect changed only by the authority, by rules the client does not have, may not be marked predictable: that is a validation failure at deploy, not a runtime surprise.

What is declared.

DeclaresWhat it is
predictable aspectswhich ones the client may step ahead of the authority
divergence thresholdbelow it a correction is smoothed; above it the authority's state is accepted as it comes. Declared, and managed — not a designer's daily knob
display mode for remote entitiesinterpolation between arrived states, or extrapolation
interpolation delayhow far the display of others lags, declared rather than tuned by feel
extrapolation windowbeyond it an entity is marked stale and extrapolation ceases
compensation windowhow far back a rewind may reach, and it is managed
what is rewoundpositions and orientations of targets, and the geometry of dynamic obstacles if declared historical

What is rewound, and what deliberately is not.

What it is
rewoundthe positions and orientations of targets, and the geometry of dynamic obstacles where the type declares them historical
not rewoundlife state — the dead do not revive in order to be shot — and ownership, score and inventory
the rule behind the splitthe decision is taken in the past; the effect is applied in the present
AlwaysWhat it is
authoritative state names the input it sawit carries the number of the last input applied, which is what makes reconciliation exact rather than approximate
divergenceis observable: the client knows its prediction was corrected, instead of quietly drifting
a view timeis a claim, not a fact: the moment the actor says it saw. Beyond the window the platform refuses rather than extrapolating: silent extrapolation is a gift to a cheater, who need only send an older time
a rewind promises no reproducibility over floating pointthe same constraint as everywhere else in the contract
one history ring, two consumersreconciliation and lag-compensated queries both read the entity's instant history track. Rewinding belongs here rather than to the modules being rewound: the ring restores the poses of the tick under dispute, and collision is then asked its ordinary overlap question about those poses — it keeps no history of its own and knows nothing of a "view tick"
client tick:  apply input locally (predict) → buffer it → send, tick-stamped
server tick:  step the same movement model → authoritative state → delta out
client recv:  authoritative state for tick T → if divergence beyond tolerance:
              rewind to T → replay buffered inputs T+1..now → smooth

Only declared-predicted fields ever roll back; tiny drift is smoothed, real divergence rewinds and replays.

Errors

  • A view time beyond the window is a conflict: send a current one. Extrapolating instead would hand a cheater the whole mechanism.
  • A history request beyond the window is likewise a conflict.
  • Two declaration defects are caught at deploy: a compensation window larger than the history buffer, and an aspect marked predictable when the client has no rules to predict it by. Neither can reach a live match.
  • Two things are not errors, and both are observable. A full input buffer suspends prediction until confirmation rather than discarding inputs silently; and divergence beyond the threshold means the authority's state is accepted as it comes, which is the declared correction and not a fault.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • The compensation window — beyond it a refusal, never extrapolation.
  • The extrapolation window for others — the entity is marked stale and extrapolation stops.
  • The unconfirmed-input buffer — prediction is suspended until confirmation; inputs are never discarded silently.
  • The depth of history for a rewind — not less than the compensation window, and that is checked at deploy.
  • The rate of actions carrying a view time — a rate limit with a deadline.
  • Simultaneously predicted entities per client — beyond the cap prediction is not performed, and that is declared degradation rather than a refusal.

User flow

A shot fired under latency, judged fair in the shooter's timeline and confirmed on both screens. The projectile and the victim's stat block are entity presets — declarations on entities, not modules you mount.

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

Not translated yet — showing English.

Predicting Your Own Motion

You act on your own input before the server has answered, and where the two disagree your client corrects itself. A mistake here costs a small visual correction and nothing else, which is why this is the one of the three prediction mechanisms it is safe to be aggressive about.

Prediction replays the declared rules, it is not a second copy of them

Your client does not run a parallel implementation of your movement. It runs the same declared model the platform runs — the model belongs to Locomotion, and prediction only applies it earlier. That is why the two sides agree most of the time: one set of rules, applied twice.

So there is no "predict" operation to call and no "correct" operation either. Prediction happens because the aspect was declared predictable.

What predicts is declared per aspect

Predictability is a declaration on the entity aspect, not a global switch:

  • An aspect the client can compute — position under your own input — may be predicted.
  • An aspect the authority changes by rules the client does not have must not be predicted. If the client cannot derive it, guessing at it produces a rollback the player reads as the game lying.

That line is where you decide what is allowed to flicker and what must be right the first time.

The correction protocol, and the two numbers that shape it

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

Authoritative state arrives carrying the number of the last input it applied, so your client knows exactly how much of its own buffer is still unconfirmed. From there:

  1. Accept the authoritative state.
  2. Replay the buffered inputs that came after the one it acknowledges.
  3. Reconcile the result against what you were already showing.

Two declared numbers decide how that feels. The divergence threshold: below it the correction is smoothed, above it your client snaps and replays. And the unconfirmed-input buffer's bound: overflow is not undefined — the degradation is declared and observable, so a client on a bad connection knows it has stopped predicting rather than quietly drifting.

Divergence is observable to the client that had it, and only to that client. You can tell that your prediction was corrected and by how much — useful for tuning, and for showing the player an honest connection indicator. You cannot read someone else's divergence: the size of a misprediction is information about their connection, not about the game. Correction is client-side, because the authority's state is what everyone else was already being shown.

If you are arriving from somewhere else

  • Unreal's Mover 2.0. The shape is familiar: tick-stamped inputs, a movement model, corrections from the authority. The difference is where the model lives — here you declare it and the platform simulates it, so there is no movement component of ours for you to subclass or replace.
  • Rollback-and-replay netcode, as in Photon Fusion. Replaying your own unconfirmed inputs after a correction is the same mechanism, and it is fully here. What is deliberately not here is re-running the world after the fact — see Lag Compensation for what happens instead, and why.

What this does not cover

Other players' entities are not predicted, they are displayed — that is Showing Other Players. Judging a shot in the shooter's timeline is a server mechanism and lives in Lag Compensation. And none of the three applies at all when the room's authority mode is external: then the tick belongs to whoever runs it, and so does the prediction.

The live game

Not translated yet — showing English.

Showing Other Players

Nobody predicts other players — they are displayed. You receive their state at intervals and draw something in between. A mistake here costs a visible jerk rather than a life, which is why it carries its own declarations instead of sharing prediction's.

The display mode is declared

For entities that are not yours, the room declares how to fill the gap between the states that arrive: interpolate between the states you have, or extrapolate past the newest one. That is a declaration on the entity, so the answer is the same on every client and does not vary with whoever implemented the renderer.

Interpolation delay is declared too. Showing other players smoothly means showing them slightly late, by a declared amount. An unstated delay is a bug report nobody can reproduce; a stated one is a number you tune against your genre.

Extrapolation stops instead of inventing

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

The extrapolation window is declared, and past it the entity stops being shown as moving rather than continuing on a guess. Extrapolating indefinitely puts a player on a target that was never there, and the player cannot tell it is happening — a visible freeze is the recoverable failure.

Why this is separate from predicting your own

The three prediction mechanisms have different authorities and different failure modes, and one word for all three means tuning one silently changes the other two.

MechanismRuns onWhen it is wrong
predicting your ownthe clienta correction, replayed and smoothed
showing other playersthe clienta visible jerk
lag compensationthe serversomebody dies unfairly

That split is also why an observer preset exists carrying this mechanism and nothing else: a spectator has no input of its own to predict, so prediction settings would configure something it does not do.

The live game

Not translated yet — showing English.

Lag Compensation

This is the server's mechanism, and the one whose errors cost a player their life — in favour of whoever has the worse connection. Everything on this page is shaped by that asymmetry.

The question it answers is narrow: what did the shooter actually see? An action may carry a view time, the tick the actor was looking at when they acted, and the platform restores the targets' poses at that tick so the shot is judged against what was on their screen.

The view time is a claim, not a fact

It arrives from the client, so it is an assertion by the caller and is treated as one. Two consequences:

  • The compensation window is bounded, and outside it the platform refuses. It does not extrapolate to be helpful: a refusal is a decision you can see, a silent extrapolation is one you cannot.
  • Reading a target's past state still obeys visibility. Asking about a historical tick is not a way around Visibility — what you could not see then, you cannot read now.

And "the hit did not count" is a verdict, not an error: a successful answer with a machine-readable reason.

What rolls back is declared, and it is not everything

Rolling back everything produces double kills: two players shoot each other, both are rewound to a moment when both are alive, both hit. Rolling back nothing cancels lag compensation itself. The boundary between them is a declared list.

The rewind itself belongs here rather than to the modules being rewound. The history ring restores the poses of the tick under dispute and Collision is then asked its ordinary overlap question about those poses — collision keeps no history of its own and nothing in it knows what a view tick is. The ring itself is the entity's history track, not a second store.

The decision is taken on the past; the effect applies in the present

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

Lag compensation answers a question about the shooter's moment of view. The consequences — damage, death, the award — apply to the current state. What happened between the moment of view and the moment of decision is not cancelled and not recomputed.

So this is observable, and it is intended: a player can get a shot off after having been killed by someone else's rewound shot. Cancelling that would mean replaying the world on top of a rewind that does not promise reproducibility, which manufactures divergence instead of removing it.

Server-side re-simulation is out of scope. Recomputing consequences against a new truth needs a fixed reference point that floating-point state does not give us. What stays is everything the module rests on: a client replaying its own unconfirmed inputs (Predicting Your Own Motion), and lag compensation as reading the past for one decision. That is how favour-the-shooter works in practice.

If you are arriving from somewhere else

  • Favour-the-shooter lag compensation as shipped in most competitive shooters: the same mechanism, and this page is it.
  • Full rollback netcode. The rewind is here; the replay of the world afterwards is not, and the paragraph above is why. If your design depends on consequences being recomputed after the fact, that dependency is the thing to raise with us early rather than discover late.

Also worth knowing

  • The implementation is overridable. If your game needs a different compensation rule, you can replace ours, and the replacement declares which of the declarations it honours.
  • There are no extension points on the prediction and correction path. Those run at tick rate, and a hook in that loop would be a hook you cannot afford.
  • None of this applies under external authority. Lag compensation exists for rooms our simulation runs. When the tick belongs to a studio's game server or a master-client, compensation belongs to whoever runs it — see Who runs the tick.
The live game

Not translated yet — showing English.

Bots

A bot joins as an ordinary player. Only the brain lives elsewhere. Same session, same entry validation, same rules, same ACL. The room cannot tell the difference, by design, so bots exercise your real game rules and anti-cheat never needs a bot exception.

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

When to use it

  • Your lobbies need filling at off-peak hours — FillRoom tops matches to a quota and bots yield seats as humans arrive.
  • Bots must play by the real rules — entry validation, ACL, visibility — so anti-cheat never needs a bot exception.
  • You bring an external brain — a learned policy, a service — that joins through ConnectAsBot like any player.
  • A disconnected player's entity must hand to a bot and back on reconnect, without the seat or prediction noticing.
  • Skip it when the character never decides — a dialogue NPC with no brain lives in World Objects.

This page is the connecting half. Getting a bot into a room, filling a lobby to quota, handing a seat between a bot and a human. Writing the thing that decides is the other half — Writing a Brain, which specifies the socket a brain plugs into.

Who does what

ActorOn this page
bot-brainconnects as a player; receives perception; sends commands
room-ownerdeclares profiles, fills rooms to quota, hands over bot/human

At a glance

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

An external brain (heavier AI, a learned policy, a service) connects like any player:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

The model

A bot introduces no notion of its own — not a participant, not an input channel, not a visibility zone, not behaviour. It is an actor credential wearing what rooms, data and locomotion already declare.

What a bot's declaration carries.

DeclaresWhat it is
thinking tickhow often the brains are asked, and it is not the simulation tick: the brains run outside, and a network call every tick is unfeasible
direction of the brainswhere they execute — a cloud function, the studio's backend, its game server. Which one is not part of the contract, and moving between them is not a breaking change
actor presetthe bot's rights, as an ordinary actor preset
visibility of the bot markerwhether participants are told. The marker itself always exists and the platform always observes it; whether players see it is the room type's declaration, because in some markets disclosing an AI opponent is an obligation and in others it is a product choice
behaviour when the brains are unavailableone of three, with no default: do nothing · leave the room · fall back to built-in default behaviour
roster fillingdeclared by the room type — how many, under what condition, until what moment. Matchmaking knows nothing about bots: it matches actors, and does not decide whom to top up with

What is true of every bot.

AlwaysWhat it is
an actor, not a playerit holds an actor credential but has no login provider, no links and no sessions
economic ownershipis none — no entitlements, no purchases, no leaderboard entries — otherwise bots end up in the standings and in the economy
perceptionis a player's: the same visibility zone, the same predicate, the same object-count limit. A bot and a player in the same position receive the same set of objects, so a bot cannot wallhack any more than a player can
wider perceptionis an actor preset, not a bot property: a debug or "omniscient coach" mode is declared as a preset with a wider predicate
between thoughtsthe last command applies, and its fate is whatever the movement type already declares for stale input — a bot whose brain is thinking is the same case as a player whose network dropped
room capacitycounts a bot: it occupies a seat like anyone else

Errors

  • The room does not accept bots is a conflict, and repeating it will not help.
  • The bot limit is exhausted is a conflict, not forbidden — the permission to introduce one is held; the room is full. Worth repeating once the room frees up.
  • A command for a bot from an actor without the permission answers forbidden, and repeating is pointless.
  • The brains being unavailable is not an error — it is one of the three declared behaviours above. Whether they were slow, down or thinking is the business of the direction that executes them, and it is not part of the contract; what is observable is what is observable for any participant.
  • Declared at deploy, refused at deploy: a bot named as the owner of a leaderboard entry, and a missing thinking tick, are both validation failures at deploy time rather than surprises in a live room.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Bots in a room — introduction is refused as a conflict; existing bots are never removed to make room.
  • Bots per project — the same conflict.
  • The thinking tick from below — a declaration faster than the floor is refused at deploy time, because a network call per tick is unfeasible.
  • The rate of commands for one bot — a rate limit with a time.
  • The deadline for a response from the brains — once it expires, the declared unavailability behaviour applies.

User flow

A host tops the lobby to quota, an external brain takes one of the seats, and the room runs on the real rules throughout.

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

Not translated yet — showing English.

Writing a Brain

A brain is ordinary code that answers one question: what does this bot do next. It runs wherever you want it to — a cloud function, your own service, a headless client — and it talks to the room through the same surface a human player's client uses. This page specifies the socket it plugs into: what a brain receives, what it may send back, and when. Connecting a Bot covers the other half: getting a bot into a room.

What is settled, and what you can build against today

The platform ships no game AI. No behaviour trees, no utility system, no navigation brain. That is the boundary: the decisions are yours, and the module's job is to make your decisions indistinguishable from a player's.

A brain is not a hook. A hook wraps a step of ours. A brain is not a step of ours at all: it runs outside the room, on its own schedule, and the platform does not care which direction the connection was opened from. That is why a brain can be a cloud function, a service you host, or a headless client, and why none of those is more native than the others.

The socket is perception in, commands out, and both sides are the player's:

What it is
perceptionexactly what a player in that seat would receive — the same deltas, through the same visibility rules. A bot cannot wallhack any more than a player can.
commandsexactly what a player in that seat would send. No privileged input channel exists.

A bot that sees more — a debug mode, a training mode — is a declared widening, not a side effect of being a bot.

The thinking tick is declared, and it is not the simulation tick. Brains are outside, so they think on their own cadence. Between two thoughts the last command stands, which is the thing to design around: a brain that thinks slowly does not produce a bot that stands still, it produces a bot that keeps doing the last thing it decided.

Brains going away has declared behaviour, and there is no default. You say what happens when the brain stops answering, per room type. "Brains unavailable" is a retained event, so a late subscriber learns the current situation rather than only future changes.

What a bot deliberately cannot be

These are refusals rather than omissions.

  • A bot is not a player, and it does not own entitlements, purchases or leaderboard records. A bot that could hold them would be a way to manufacture them.
  • The bot flag always exists and is always observable to the platform. Whether your game shows it to players is your decision; whether it exists is not.
  • The module keeps no history of what a bot decided or why. That is your business, in your telemetry — the platform is not the place your AI's reasoning is stored.
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.

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

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 needs teeth — revoke sessions, suspend, device-ban, with a banned event every live system hears at once.
  • 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 is the part with teeth:

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

What no handler may do is decide who signed in. A gate answers yes or no about an identity the platform has already established; it does not name the player, hand out an identity, or stand in for the provider's confirmation. That line is the difference between overridable sign-in and skippable sign-in.

The model

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

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, not to be told no
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. Conflating them makes "banned anonymous" or "suspended registered" inexpressible
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
revoking is not instantand we do not pretend it is. 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
the default roleis declared per project: 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 at all, which is a legitimate declaration rather than an omission

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. Collapsing the two would have clients hammer a provider that already said no.
  • The sign-in attempt rate exceeded answers in the rate-limit category with a deadline.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • 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.

User flow

A guest account on first launch, upgraded to Steam later with progress intact.

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Not translated yet — showing English.

Profile

A profile is a view, and the platform owns almost none of it. What the platform keeps about a player is the player_id and the system profile behind it — identities, sessions, provider links, all of that in Auth. Everything a player has is your own entity, owned by that player. A profile is the set of those entities your project declares, read for one owner in one pass.

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

When to use it

  • A screen needs one player's slice in one call — the declared set fans out across their owned entities instead of the client stitching several queries together.
  • Platform surfaces must show a person, not an identifier — a leaderboard, a moderation queue and a support ticket hold a player_id and nothing else until the project names the record that displays a player.
  • Another player needs a card — the same read against another owner, narrowed by the row predicate and column mask already declared in Access.
  • A HUD must track owned state live — the read is a selection, and a selection subscribes.
  • Skip it when the data is not owned by a player — shared and global rows are an ordinary Entity selection, with no owner to fan out from.

Who does what

ActorOn this page
schema-authormarks entities as player-owned and declares which of them form the profile
playerreads their own profile; writes go to the entities themselves
room-visitorreads another player's profile, as far as that player's predicate and mask allow

At a glance

Membership is declared per entity, not per field. The entity says it belongs to the profile; what another player may see of it is the column mask on the role that reads it (Access). A field-level view attribute would be a second answer to the question access already answers, and the two would drift the first time somebody edited one of them.

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

The read is an owner-scoped selection — the Entity query surface with the owner fixed and the entity list taken from the declaration. Profile is the name of that read, not a module standing behind it: same rights, same predicates, same filters, same subscription, because it is the same operation.

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

The model

ConceptWhat it is
player_idthe platform's whole idea of a player, plus the system profile behind it — Auth
profile setthe player-owned entities the project declares as its profile; declaring it is optional
owner selectionthe read: one owner in, their rows across the set out — the same rights, predicates, filters and subscription as any Entity selection
public readthat selection against another owner, narrowed by the reading role's row predicate and column mask (Access)

What is true of every profile read.

AlwaysWhat it is
there is no profile recordit has no identifier of its own, no revision, no history and no lifecycle, because it is a view over rows that have all four
writes go where the data livespatch the progress row, and every profile read that includes it sees the new value on its next pass
there is no public writea view has nothing to write to, and shared-writable state goes through server code
declaring the set is optionaland not declaring it is not the same as declaring an empty one: a project with no profile set has no profile read at all and the call is refused as unavailable, where an empty result would say the player has a profile and it happens to be blank
ownership is a predicatenot a column the platform adds (Access): owner == caller.player is one instance of the mechanism, "one of the participants" is another
a derived field belongs to a hooka post-change observer is what stamps Progress.Title when Level crosses a threshold. It follows the write; it cannot refuse it

Errors

  • A project with no declared profile set has no profile read, and the call is refused as unavailable — not answered with an empty result, which would say the player has a profile and it happens to be blank.
  • A row a predicate hides answers not found, and so does one that does not exist: a public read never becomes a way to learn what exists but is not visible.
  • A field outside the reading role's mask is absent from the answer, not present and empty.
  • There is no public write. A view has nothing to write to, so shared-writable state goes through server code rather than through this surface.
  • A write on a player's behalf that does not name the player is a validation refusal.
  • Declaring the profile set is a schema act — fn or adm. A player key attempting one is refused forbidden, and the push is rejected whole rather than declared in part.

Limits

Each ceiling names its behaviour at the edge; the numbers land with the platform limits chapter.

  • The page size of the owner selection — trimmed to the cap with the "there is more" flag still true; returning fewer without the flag is forbidden.
  • The size of an included set per row — trimmed by the same rule, with the flag on the inclusion.
  • The rate of changes to one instance — a rate-limit refusal with a deadline; the profile read is a selection like any other and inherits entity's ceilings rather than declaring its own.

User flow

From the lobby painting to the level ticking over: a server-side write reaches a subscribed screen without the screen asking again.

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Not translated yet — showing English.

Social

One new concept, and everything else is built from what you already have. A relationship of two actors, with a state of its own and an initiator — that is all this module adds. A clan, a guild or a squad is a group with a relationship layer over it, not a second kind of thing; and blocking, which several modules need, lives here so that only one place owns it.

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

When to use it

  • Players need each other by name — friends, followers, block lists.
  • A clan or guild needs a door — an invitation from the group, a join request from an actor, and a decision on either.
  • A friend list has to show who is online — presence is derived from sessions, and who may see it is a predicate you declare.
  • Another module needs to know somebody is blocked — it reads that state from here rather than keeping its own.
  • Skip it when the thing is a set of actors rather than a pair with a state: that is a group, and a group per pair would mean millions of two-person groups, each with its own lifecycle and entry rules.

Who does what

ActorCanCannot
playerpropose a relationship or follow; accept, decline or withdraw; break a mutual one; block and unblock; read their own relationships and the presence of related actors; subscribe to changes; submit a join requestread anyone else's list of relationships, under any participant relationship whatsoever
moderatordecide on invitations and join requests where they hold the membership-administration atomdecide on an intent they hold no permission for — that answers forbidden

The model

What a relationship declaration carries.

DeclaresWhat it is
kindsymmetric — the pair needs both sides to agree, and the state machine below is about it; or one-sided — following, whose only state is active. Uniqueness per pair and the idempotency of a proposal hold for both
re-invitation ruleafter a refusal: forbidden · permitted after a declared period · permitted at once. Declared, because "ask again" is a product decision
presence visibilitya predicate — to everyone · only to mutually connected · to nobody. There is no default
joining mode (on the group type)open · by request with a decision · by invitation only
retention of declined and brokenafter the declared period the relationship is removed, and re-inviting becomes possible again regardless of the re-invitation rule

The states of a symmetric relationship.

StateMeaning
proposedthe initiator proposed and the other side has not answered
mutualboth sides agree
declinedthe other side refused. The relationship is kept, because the re-invitation rule needs to know
brokenone side left a mutual relationship
blockedone side blocked the other

What is true of every relationship.

AlwaysWhat it is
one entity per pairnot two mirrored records. "A proposed to B" and "B was proposed to by A" are one fact read from two sides
an initiatoris declared: who proposed, which display and the re-invitation rule both need
blocked dominatesfrom it there is no transition to proposed or mutual
a blockis asymmetric in control, symmetric in effect: only the one who set it may lift it, and it acts both ways
a refusal on a blockdoes not reveal it: the operation answers not found, so a blocked actor cannot discover the block by probing
the block stateis owned here and consumed elsewhere: messaging and others read it; none of them mutates it, and none keeps a copy
presenceis derived from sessions: not written by anybody, and the visibility predicate is applied per requester rather than once per actor
a deferred intentdoes not occupy a seat: an invitation or a join request never counts towards the group's capacity — otherwise a hundred requests exhaust a clan of fifty and nobody can join
a groupkeeps an administrator: at least one actor must hold the membership-administration atom, and the last one cannot simply leave: a clan whose last administrator walked out could never admit anyone again
no intra-group roles"clan officer" is an actor holding an atom, not a rank stored in a list
an import never overwritesrelationships brought in from a login provider are additive: someone blocked does not become a friend because a provider says so

Errors

  • Already mutual is a conflict; there is nothing to propose.
  • A proposal to oneself is a validation refusal.
  • One of the sides has blocked answers not found — not forbidden, because a refusal that distinguished them would reveal the block. Repeating is pointless.
  • A re-invitation before the deadline is a conflict, worth repeating after it.
  • A limit exhausted — relationships, intents — is a conflict, not forbidden: the permission is held, the room is not there. Retry once one frees up, or once the existing intents have been decided.
  • An expired intent is a conflict: create a new one rather than retrying the old.
  • The departure of a group's last administrator is a conflict until the permission has been handed on.
  • Deciding on someone else's intent without the permission answers forbidden, and repeating is pointless.
  • An import from a provider that is not connected answers unavailable — retry with backoff.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Mutual relationships per actor — a proposal is refused as a conflict; existing ones are never broken to make room.
  • One-sided relationships per actor — a new one is refused; existing ones stay.
  • Outgoing proposals — a new one is refused, and there is no eviction: an evicted invitation would be indistinguishable from a declined one.
  • Blocks per actor — adding one is refused as a conflict, and older blocks are not evicted; somebody silently unblocked starts writing again and nobody knows why.
  • An intent's lifetime — expired, with an event.
  • The rate of proposals per actor — a rate limit with a time.
  • The rate of presence changes in the stream — bounded by the update rate rather than by discarding changes.
  • Retention of declined and broken relationships — removal by the declared period.

User flow

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Not translated yet — showing English.

Messaging

Rooms, groups, players: one addressing model for chat and notifications. Messages arrive in a conversation; conversations are channels with history, moderation and out-of-band delivery on top.

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

When to use it

  • Players talk — room chat, guild channels, DMs — over the addressing you already have: room, group, player.
  • Offline players must still hear — templated, schedulable notifications deliver out-of-band by push.
  • Moderation must run before delivery — a pre-send hook filters or rejects, and mute/block is platform-enforced everywhere.
  • Returning players need catch-up — History(take: 50) pages the conversation on next launch.
  • Skip it when the payload is game state, not conversation — synced fields in Data and core channels already fan that out.

Who does what

ActorOn this page
playersends and receives messages; reads history; mutes or blocks
moderatorfilters, redacts and bans terms
backend-servicesends or schedules templated notifications

At a glance

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

A structured message is a declared event, and the conversation then carries it by name — no payload class to construct at the call site:

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

The declared message arrives typed in the same subscription, so a client that knows RallyCall gets fields rather than a blob.

Notifications are out-of-band, templated and schedulable — and they are sent from fn or adm authority, never from a player session:

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

Moderation as hooks, same contract as everywhere:

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

The model

What a conversation type declares.

DeclaresWhat it is
the groupits roster — a participant is an actor, exactly as in groups
binding to a lifetimeoptionally another entity's, so a room chat disappears with its room

Where the line runs between the envelope and the payload.

PartWhose it is
envelopethe platform's: the author, the conversation, the moment by the declared clock
payloadthe studio's, declared as a message type with typed fields, and appearing as its own send surface rather than as an untyped bag

Three things this module owns that a plain event does not — the order within a conversation, the retention period, and the subject of moderation. That is why chat is not "an event with history": a player's ordering, history and moderation are the platform's concern here and are not in events.

The three machines.

OfStates
a conversationcreated → active → closed
a messagesent → published | rejected by the filter, and then edited or deleted, observably
a notificationcreated → queued → delivered | expired

What is true of every message.

AlwaysWhat it is
order within a conversationis stable and declared. Order between conversations is not promised
editing and deletingare observable: a message never vanishes silently — otherwise a client's history and the server's diverge with nobody knowing
historyis the messages themselves: with a declared retention period, read in pages by cursor from a position
retention outlives the complaint windowthe period is no shorter than the time allowed to handle a complaint: a complaint arrives after the message, and a message that no longer exists leaves nothing to handle
read stateis a position, not a flag: one position per actor per conversation, and marking read is monotonic — the position never decreases, so a repeat call cannot undo progress. The unread count is a derivative of that position rather than a counter of its own
sendingis idempotent by key: two calls are two lines of dialogue, so the key is what makes a retry safe
blockingis a delivery predicate, not a refusal to send: the sender is not told, because a refusal would reveal the block. The state itself lives in social
the sender composes the payloadthe platform does not read the recipient's data to fill in your text. The recipient's locale may be a declared context claim that travels to the extension point, so substitution and translation are the hook's work — the one place that knows both the recipient and their locale
the delivery routeis not part of the contract: push, in-app, or something else is a routing decision, not a promise
deliveryis observable within declared bounds: "queued" always; anything beyond that as far as the route can report

Each extension point names the type it hands the hook: the outgoing message before publication, the published message after. A filter may correct the content it was handed — masking a word is a correction — but never the sender or the conversation.

Errors

  • A conversation that does not exist or is hidden, and an actor who is not a participant, both answer not found — so a refusal never reveals a conversation you are not in.
  • A closed conversation is a conflict.
  • No permission to write in this type answers forbidden, and repeating is pointless.
  • Rejection by the filter is a verdict, not a refusal. The call was performed, the content was considered, the decision is negative and the reason is a declared value — which is why it is distinguishable from a refusal by permissions, and why what to do next depends on the reason.
  • The filter being unavailable answers unavailable and is worth retrying with backoff — but nothing was published in the meantime.
  • An undeclared message type for this conversation, and an oversized message, are validation refusals; content is never truncated silently.
  • The send rate exceeded answers in the rate-limit category with a deadline.
  • Editing another's message answers forbidden.
  • A notification past its expiry is a conflict: send a new one.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Message size, and attachments with their size — the send is refused as a validation failure, never truncated. The files themselves are Files & UGC'.
  • The send rate per actor — a rate limit with a deadline.
  • The depth of history — past the period a message is evicted from retention with an event, rather than disappearing quietly.
  • Conversations per actor — joining another is refused as a conflict.
  • Notifications queued per actor — a new one is refused, and eviction is forbidden: a silently discarded notification is indistinguishable from one that was never sent.
  • A notification's expiry — expired, with an event.
  • Participants in a conversation is groups' limit, and blocks per actor is social's — neither is restated here.

User flow

One rally message reaches the whole guild. Two roles split the delivery: the online-member, who is in the conversation when it lands, and the offline-member, who gets a push and reads the rally out of history on the next launch.

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Not translated yet — showing English.

Catalog & Commerce

Items, prices, wallets, storefronts, purchases, entitlements. Real store integrations where the platforms allow them (Stripe, App Store, Google Play, Steam, Xbox); scheduled, audience-targeted storefronts; and a purchase flow whose every step is hookable.

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

When to use it

  • You sell things — for real money through Stripe, App Store, Google Play, Steam or Xbox, or for wallet currency.
  • Storefronts must resolve per player — schedule, audience and price computed server-side, never eligibility math in the client.
  • Pricing rules belong in one testable hook — discounts, repricing and vetoes run before any charge.
  • Receipts must be replay-proof, and a refund must revoke the entitlement through the same events the grant used.
  • Skip it when items are never sold — though rewards still land through commerce's one Grant with origin reward (Leaderboards cycle chests arrive that way), so even a shopless game keeps a single auditable grant ledger.

Who does what

ActorOn this page
playerbrowses storefronts, purchases, manages wallet, redeems codes
sellerconfigures catalog, prices and storefront schedules
backend-servicevalidates receipts; reprices or grants via purchase hooks

At a glance

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

The model

What a catalog item declares.

DeclaresWhat it is
keyit is authored content, addressed by a key so a rename in code is a rename
kindconsumable — it is spent; or durable — owned once
pricesa price is a monetary quantity: an integer in minor units plus a currency code, never a float. An item may carry several — game currency and real currency both
external identifier per providerone slot per provider, declared, because a store knows the item by its own id
what it points atoptionally an entity of any declared kind, and buying the item then grants ownership of that entity

What composition is allowed to be.

What it is
what is purchasablea catalog item, never an arbitrary entity: a price with nobody serving it is not a promise, because a purchase needs someone who grants the right and answers for the refund
two levels, no thirda bundle is a catalog item made of items; a storefront is a set of offers, and an offer points at an item and may override its price and a bundle's contents
a territorial priceis expressed by a storefront rather than on the item

What a storefront declares.

DeclaresWhat it is
offersthe set, each pointing at an item
schedulein wall-clock time, always UTC: when the window opens and closes
audiencea predicate, not a list of players — so the audience is a rule that keeps being true rather than a snapshot

A storefront whose audience a player does not fall into does not exist for that player.

An order's states.

FromTo
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid or grantedrefunded
AlwaysWhat it is
the priceis fixed in the order at the moment it is created, so a price change afterwards cannot alter what was agreed
awaiting paymenthas a declared deadline, declared per provider, because they differ
grantingis separate from payment: paid and granted are different states: money arriving and the thing appearing are two facts, and conflating them hides which one failed
a refundis an external transition: it arrives without any request from us, at any time, and what happens to what was granted is declared — there are three answers and no default
an entitlementcarries its origin — a purchase, a promo code, a reward, a gift — so "where did this come from" is answerable a year later
a consumable entitlementaccumulates: it changes by an increment with an idempotency key, never by overwriting what was read
ownershipis an owner predicate: an entitlement belongs to a player by the same mechanism as any owned row
the catalogis declared in code and reaches the panel under the seed ownership mode by default: code creates what is absent, and a designer's edits survive the next push
provider secretslive in the operator plane, never in the declaration, and never in a repository
a provider's capabilitiesare declared: whether it has a usable API at all, and what it can do — so a catalogue does not promise a flow the store cannot serve

Each extension point names the type it hands the hook: a purchase intent before the purchase — player, offer, provider, price — and the purchase itself after. A hook never receives an untyped bag.

Errors

  • Outside the audience answers not found, and repeating is pointless. Outside the schedule also answers not found, but is worth repeating once the window opens.
  • The provider is unavailable and the provider declined the payment are deliberately different answers: the first is unavailable and retryable with backoff, the second a conflict that retrying will not fix. Collapsing them would have callers retry a decline forever.
  • An invalid receipt is a validation refusal; a receipt already consumed by another order or another player is a conflict — that is what makes replay useless.
  • The price changed between reading the storefront and buying is a precondition failure: re-read and decide again, rather than being charged the new price silently.
  • Insufficient game currency is a conflict, not forbidden — the permission to buy is held, the balance is not there. Worth repeating after topping up.
  • A durable entitlement already held is a conflict.
  • The region or age does not permit the purchase answers forbidden, and repeating is pointless.
  • The order's deadline has passed is a conflict: create a new order.
  • The spending limit exhausted answers as a conflict or a rate limit depending on which limit it was, and it states when the limit resets.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • The catalog's size — publishing another item is refused as a conflict.
  • Storefronts per project — creation is refused.
  • Offers in a storefront — an addition is refused; the storefront is never truncated silently.
  • The lifetime of an order awaiting payment — a transition to expired, with an event.
  • The rate of purchase attempts — a rate limit with a deadline.
  • The spending limit per period — a conflict that states when the limit resets.
  • Retention of orders — past the period an order becomes unreadable by the declared period rather than vanishing without explanation.
  • Entitlements per player — a grant is refused, and those already granted are never evicted.
  • A price's precision is not a limit but a type — an integer in minor units.

User flow

A new player's first purchase: the storefront resolves, the price halves, the item lands — and the sale reaches the first-buy funnel the operator reads in analytics.

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Not translated yet — showing English.

Inventory

Everything integrates here. Shots debit ammo, drops land in it, abilities check it, movement is modified by it — one owned set of rows, with stacks that increment and a per-owner cap whose behaviour at the edge you choose.

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

When to use it

  • Players hold things, and a holding is a row with an owner — read by owner, capped per owner, with the overflow behaviour declared rather than defaulted.
  • A quantity accumulates — a stack changes by an increment with an idempotency key, so a retried debit does not debit twice.
  • Other modules spend from one set — shots debit ammo, drops grant loot, purchases show up as rows against their entitlement.
  • Skip it when the number is not ownable — hp, xp and cooldowns belong in stats.

Who does what

ActorOn this page
playerreads their own holdings and spends from them
backend-servicegrants, increments and revokes on a player's behalf, naming the player it acts for

At a glance

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

A player session runs the reads, the moves and the affordability check with the same calls. Grant, consume and destroy are not its to make: the platform refuses them as forbidden and names the right the caller is missing, whatever binding made the call.

The model

An inventory introduces no notions of its own. It is a preset — a shape assembled from what entity already gives, so everything below is an entity declaration rather than a mechanism of this page. A preset that needed a new kind of declaration would be a gap in the contract, not a reason to extend the preset.

DeclaresWhat it is
an owned typethe holding belongs to an owner, and selection by owner is entity's own operation
a ref to a catalog itemthe reference stores the item's id and never its key, which is exactly what makes renaming a key safe. The definition itself lives in commerce
a stack aspect with an incrementa stack changes by a delta rather than by overwriting what was read. The increment is not idempotent by nature — two increments are two increments — so it is obliged to accept an idempotency key, and the way to establish the outcome is an addressed read
a per-owner cap with its boundary behaviourone of three, and there is no default: refuse · redirect into a declared owner bucket · discard with event

What is true of every holding.

AlwaysWhat it is
the cap has no defaultthe three answers to a full bag are three different games — a refusal loses the loot in front of the player, a redirect is mail or an overflowing warehouse, a discard is a quiet loss that is lawful only because it was declared and is observable. No default is right for all three, so the declaration chooses
the owner is immutablenothing changes hands by editing a field: a holding moves as a revocation plus a new grant with a declared origin, and both facts stay on the record. Editing the owner instead would erase the trail, leaving "where did I get this" and "it was taken from me" with nothing behind the current state
a transfer between two playersis a different promise: it needs escrow and anti-fraud, and it is outside this version
a rowdisplays an entitlement rather than being a second source of one — what was bought lives in commerce, and the row here represents it

Errors

  • The instance does not exist, or a predicate hides it — the answer is not found either way, so a refusal never reveals that something exists but is not yours.
  • A field not declared in the aspect (nested ones included), and an obligatory field with no value, are validation refusals naming the field.
  • The version did not match is a precondition failure, worth repeating after re-reading.
  • A write on a player's behalf that does not name the player is a validation refusal, not a silent write as somebody else.
  • No permission for a read or a write answers forbidden, with the read and the write distinguished.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Instances per owner — by the declared rule above, and there is no default.
  • The stored instance's size — the write is refused as a conflict, and the refusal names the offending field and the measured size. The ceiling is reached by accumulation, so the approach to it is observable before the write that fails.
  • The rate of changes to one instance — a rate-limit refusal with a deadline.
  • The selection page size — the page is trimmed to the cap and the "there is more" flag stays true; returning fewer without the flag is forbidden.

User flow

One shot's ammo, from the cast that debits it to the crate drop that grants it back. The ability, the projectile, the crate's stat block and the drop-table in it are entity presets — declarations on entities, not modules of their own.

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Not translated yet — showing English.

Leaderboards

Every mechanic, systematised. Not a catalogue of board types. One model whose axes compose into all of them: daily standings, best-lap boards, guild totals, seasons, tournaments.

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

Read that block as: who acts on this page (actors), what the module hands you (provides), which modules it stands on (builds-on), and where it hangs off the root — mounts: root means playserv.Leaderboards, not a namespace under another module (how modules mount).

When to use it

  • Scores must rank players — daily standings, best-lap boards, guild totals — as one declared model, not a system per board.
  • You need the standard reads — top-N, around-me, a named list of owners — without extra data modelling.
  • Cycles must close on schedule, archive (never delete) and fire a reward hook with the final table.
  • Suspicious scores must never enter the table — a pre-submit hook validates, caps or rejects with a typed reason.
  • A tournament is the same board with an entry window, max entrants and attempts per cycle.
  • Skip it when the number is never compared between players — a personal counter or a career total is ordinary data. The module orders results; it never computes them, and it runs no elimination bracket.

Who does what

ActorOn this page
playerreads top-N/around-me/own rank, subscribes to rank changes
backend-servicesubmits results; corrects or rejects them in the pre-submit hook; grants rewards when a cycle closes
operatordeclares boards; closes a cycle early, corrects records (audited), watches submission rates

At a glance

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

The declaration lives beside the rest of your schema — in the server project, or in the UE or Unity project — and playserv push compiles it and sends it up: the board appears in the panel, empty, with its next reset already scheduled. Schedules are UTC, so this board closes Monday 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") moves the hour. Per-player local time is not a reset option — one table cannot close at twenty-four different moments.

The order key is a list, not a score plus a tie-break. Fields rank in the order you number them, each with its own direction, and the last tier is the platform's: on equal keys the earlier submission ranks higher, so two identical runs never swap places between two reads. A field outside the key — Map here — is carried for display and never moves a row.

Agg says what a second submit does to the one record an owner has in the current cycle:

AggA second submitIdempotent
Setreplaces the record with the submitted valuesyes
Bestreplaces it only when the new values rank higher under the order keyyes
Incrementadds the submitted values to the record — kills, laps, guild contributionno — carry an idempotency key
Decrementsubtracts themno — carry an idempotency key

A submit that does not beat a Best record is not an error: it comes back accepted, order unchanged. Increment and Decrement are the two a retried call would apply twice, so they take the same idempotency key as every other retryable write.

Submitting is one call, and on this board it comes from server code because the declaration said so:

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

Three things in that call are worth reading off separately:

In the callWhat it is
PlayServ · playservthe cloud-function handle and the client instance the SDK hands you at startup. Same API, two callers — in Go they are ps and psv, and each snippet uses the one its caller has
the submitterthe function that owns the match result. In Tanks that is the room's on dispose hook (Rooms), which runs with the final state in hand
playerIdthe platform player id from Auth, never a name you chose: a hook reads it off its payload (e.By.PlayerId in the lesson), and a room host submits the id of the seat it owns

The values are the fields the declaration named — an undeclared field is refused, not stored.

The reads every game needs, and the subscription that keeps them current:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) is a window by rank, not a page: five rows above you, five below, plus your own — eleven rows, trimmed symmetrically where the table ends, so rank 2 gets a shorter window on both sides rather than a shifted one. Top is paged: it returns the first N rows and a cursor, and after: walks the rest.

ForOwners is how a friends board works. The platform holds no friend graph; you pass the owners your game already has — a group's members, or a list of ids from your own data — and each row comes back with its rank in the full table, not a rank within the list.

OnRankChanged delivers the local player's own rank and nothing else: a board with fifty thousand entrants does not push every reshuffle to every client. The callback receives the changed row — rank, the ranked fields, the display fields — and Cancel() ends the subscription. Rank itself is a snapshot: two reads a second apart can differ while submits land, though your own submit is always visible to your own next read.

The model

What a board declares.

AxisValuesHow you set it
Ownerplayer · groupOwner = Owner.Player — a guild board is the same board with Owner.Group
Order keyone or more declared fields, each ascending or descending[Rank(1, Sort.Descending)] int Score
Aggregationset · best · increment · decrementAgg = Aggregation.Best
Reseta schedule in UTC; a cycle expires, never deletesReset = Reset.Weekly(DayOfWeek.Monday)
Who may submitserver only (the default) · playersSubmit = Submit.ServerOnly
Display fieldsdeclared and typed; never part of the order[Display] string Map
Owner listchosen at read time, not declaredForOwners("weekly-score", ids) — friends, a guild, a lobby
Tournament rulesentry window · max entrants · attempts per cycle · join-requiredRules = Tournament.Define(…), in the table under Tournaments

There is no scope axis: a board per region, per room or per season is a board per key, and the key is what your code references.

What is true of every board.

AlwaysWhat it is
direction and operatorare immutable after the first write: changing them would silently re-rank history; the way to change a mechanic is a new generation, not an edit
exactly one entry per owner per generationa second is not a second row
an entryis not an entity: no lifecycle of its own, no machine: it is created by the first submission and changed by the operator the board declared
fields outside the order key never affect the orderthey are display, and that is why they are declared separately
a generationexpires, it does not delete: open → expired → evicted from retention, and expired generations stay readable for the declared retention period
the schedule transitionis observable by an event, so a handler reads exactly the table that closed rather than the empty one that just opened
the default submitteris the server: who may submit is declared, and the default is not the player
a boardis authored content: declared in code, addressed by a key, reaching the admin console, under the seed ownership mode so a designer's schedule edits survive the next push
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

What a cycle is, and what closing one does.

What it is
a resetcloses a cycle rather than deleting it
a closed cyclestops taking submits and stays readable under its label — Top("weekly-score", 100, cycle: label), a read parameter rather than an export job
the close eventcarries that label, so a handler reads exactly the table that closed and not the empty one that just opened

The two hooks on a board, and their kinds differ.

HookWhat it may do
pre-submita gatekeeper, called before the write, and the platform waits for it. It returns one of three verdicts: accept, accept a corrected submission, or reject. It may correct the values against your own entities or cap them; it may not change the record's owner or its board, which are already claimed. It fails closed, and a rejection reaches the caller as a typed Problem (Core)
cycle-closedan observer: fired after the fact, it cannot veto, and a failure there leaves the cycle closed
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

The reward is a Commerce grant rather than a mechanic of this module: chest.gold is a catalogue id, and the reward origin is what separates the grant from a purchase — refunds, revocation and the entitlement-changed event work on it exactly as they do on a bought item.

Tournaments. A tournament is this board plus participation constraints — there is no second mechanism and no separate entity. The four constraints, with their units and their behaviour at the edge:

ConstraintDeclared asAt the boundary
entry windowentryWindow: TimeSpan — how long joining stays open after the cycle opensa join after it closes is refused; the cycle still runs to its reset
max entrantsmaxEntrants: int — records in one cycleentrant 65 of 64 is refused as a conflict, and nothing is evicted — a board that dropped its worst rows would rank whoever arrived first
attempts per cycleattemptsPerCycle: int — submits per ownerthe next submit answers "attempts exhausted" — a conflict, not a permission error, and the counter resets with the cycle
join-requiredjoinRequired: true — entrants are a membership, not everyone who playsa submit from a non-entrant is refused

A daily tournament declares all four end to end.

Errors

A player session calling an operation this board reserves for fn — a submit to a server-only board, an early cycle close — is refused as a permission error before anything is written; the same call from a cloud function goes through. A submit into a cycle that has already closed is a conflict instead: the right is there, the cycle is not, and the retry is a submit into the current one.

Limits

Every limit with what happens at its boundary.

LimitAt the boundaryNumber
rows per readthe page is trimmed, "there is more" stays true, after: continuespage cap set per project
window around an ownertrimmed symmetricallywindow cap set per project
records in one cyclethe submit is refused as a conflict; no evictionmaxEntrants per board; unbounded when unset
attempts per owner per cycleconflict "attempts exhausted", cleared by the resetattemptsPerCycle per board; unbounded when unset
submit rate per ownerrate-limit refusal carrying the moment a retry is allowedrate set per project
boards per projecta new declaration is refused at deploylimit set per project
retention of closed cyclesthe cycle leaves storage with an event; reads then answer not-foundretention window set per project

User flow

One week of the weekly-score board: server-side submits, an around-me read, the Monday close and its rewards.

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Not translated yet — showing English.

Files & UGC

Files arrive as chunks and are processed as they arrive. Uploads, assets and their derived variants, and player-generated content with a moderation path.

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

When to use it

  • Players or services upload blobs — chunked, resumable sessions with readable per-player quotas.
  • Processing must start before an upload finishes — read the file as a stream, chunk by chunk.
  • Player-made content needs a moderation path — SubmitUgc, a queue, a verdict, hooks on both ends.
  • One master image must serve many platforms — derive variants (resize, transcode) and keep the original canonical.
  • Skip it for small structured payloads — a Data record field carries them without an upload session.

Who does what

ActorOn this page
playeruploads chunks, reads streamed files, submits UGC
moderatorreviews the queue, approves or rejects submissions
backend-servicederives asset variants; hooks upload and moderation; sets quotas

At a glance

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC, the player path:

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

The gates around it are hooks, same contract as everywhere:

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Both hooks wrap an operation step, never an event: Before(Files.Upload) decides whether the upload starts, After(Files.SubmitUgc) runs once the submission exists and puts it in the moderation queue through the module's own Moderation.Enqueue. Events — upload completed, variant ready, UGC submitted, moderation verdict — go to subscribers, and a subscriber vetoes nothing.

The model

A file is opaque bytes plus declared metadata — origin, content type, size — and it is not a store of state on which decisions are taken. Its link to the game model runs the other way: a field on your type holds the reference; the file does not know about the game.

What a file kind declares.

DeclaresWhat it is
originauthored content, game-generated, or user-generated — and the size limits and policies follow from it
admissible content typesas a declared list, never sniffed from the bytes
size limitschecked when the session is opened, by the declared size, rather than on the last part
part size and orderthe upload is performed by a session: a declared part size, the order of parts, a resumption point
derivativesoptionally, named variants produced by a handler — and every declared variant's readiness is observable, so a client never guesses whether the thumbnail exists yet
storage prefixon the schema field carrying the reference: where the bytes live, and nothing more. Not a directory — no renaming, no moving, no permissions on a prefix, no recursive operation. The file is still addressed by its id or key, and the prefix takes no part in that

What is true of every file.

AlwaysWhat it is
completionis idempotent by session: a repeat completion returns the same file rather than a second one
a checksumis obligatory, and a mismatch is a refusal, never a silent acceptance of corrupted bytes
a published fileis immutable: an edit is a new version, and a reference to a version keeps pointing at what it pointed at
authored contentis addressed by key plus version, and it is managed: not edited in the admin console, because the code owns it
an unfinished sessiondies observably: past its deadline it is terminated with an event and its parts are freed
ownershipfollows the owner predicate: user- and game-generated files have an owner like any owned row, and an owner's files obey the player-deletion policy — cascade, refusal or anonymisation, declared rather than assumed
read accessmay depend on an entitlement: a paid asset is gated by commerce's entitlement rather than by a second permission system

Each extension point names the type it hands the hook — the upload intent before the upload, the submission after — so a hook never receives an untyped bag.

Errors

  • No entitlement answers not found, not forbidden — otherwise the list of refusals reveals which add-ons exist. A taken-down file answers the same way.
  • An expired session is a conflict: open a new one.
  • A part outside the declared order or size, an undeclared content type, and a size beyond the limit are validation refusals — and the size one lands when the session opens, not after the bytes have travelled.
  • A checksum mismatch is a validation refusal worth repeating: resend the part.
  • The quota exhausted is a conflict, retryable after freeing space.
  • An expired read grant answers not authenticated — request a new grant rather than treating it as a permission problem.
  • Rejection by review is a verdict, not a refusal: the submission was considered and the answer is negative with a declared reason, so what to do next depends on the reason.
  • The upload rate exceeded answers in the rate-limit category with a deadline.

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • A file's size by origin — the upload is refused before any part is accepted, not on the last one.
  • The part size — the part is refused as a validation failure.
  • The session's lifetime — expired with an event, and the parts are freed.
  • Storage quota per project and per player — a new session is refused as a conflict, and what is already published is never deleted silently to make room.
  • Authored-content versions kept — the oldest is taken down, and a version referenced by an environment in force never is.
  • The upload rate per actor — a rate limit with a deadline.
  • Retention of game-generated content — past the period, a takedown with an event.

User flow

One player-built level, from the first uploaded chunk to the approval verdict.

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Not translated yet — showing English.

Analytics

Everything that needs to be counted later rather than seen now. Declare a typed telemetry event, emit it, and it lands beside the platform's own — a level completed, a funnel step, an economic event, session length, a drop-off in the tutorial. This module emits; it does not read, does not aggregate, and does not ship anything anywhere itself — the direction a batch travels is the router's, in Extensibility.

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

When to use it

  • Something must be counted later — a funnel step, a level completed, an economic event, session length.
  • The comparison has to survive game builds — a type carries a schema version, so a year-old funnel is not silently a splice of two different meanings of one field.
  • The volume is high and a lost row is acceptable if you said so — telemetry is the only place in the contract where declared loss is lawful.
  • Skip it when someone has to react — a telemetry event has no subscribers at all; a fact others must hear is a game event.

Who does what

ActorCanCannot
any actordeclare types in schema; emit on its own behalf, one at a time or as a batch; read the declared typesfill in the context; read, query or aggregate what was emitted
backend-servicethe same, and emit on behalf of a player by delegationread telemetry — there is no read permission, because there is no read operation

At a glance

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

The definition is schema, so what arrives carries named, typed fields rather than a JSON blob — and it is declared in its own bearing form, not as a game event with a flag, so which one it is can be told from the declaration without running anything.

The model

What a telemetry event type declares.

DeclaresWhat it is
namethe type's own
fieldstyped by the platform's type system; a field mask applies to them as everywhere else
schema versionmandatory, and not derived from the SDK version — a funnel compares events collected under different game builds, and without a version the comparison silently mixes the incomparable
samplingwhat share of events of this type gets through. Declared on the type, never chosen by the implementation according to load: a share that changes by itself makes funnels incomparable between days, and that is noticed only after decisions have been made on them
loss tolerancewhether this type tolerates loss. Telemetry is the only place in the contract where declared loss is lawful
deletion behaviourhow a player's deletion reaches this type — by deleting or by anonymising. The studio declares the policy; the module carries it out

What is true of every telemetry event.

AlwaysWhat it is
no addressing targetno recipient, no group, no subscription. Wanting to address one is the sign that a game event is what is needed
contextis the platform's: it adds the actor or a marker of anonymity, the session, the environment, the build version, and the moment by the declared clock. The caller cannot fill it in: a caller that substitutes the actor or the build version gets the derived values instead of the passed ones
the sampling sharetravels with the event: without it the absolute number cannot be reconstructed from what arrived
lossis observable in aggregate: the share undelivered over a period is available to the consumer; per-item observability is not promised, because at telemetry volumes a message per loss would itself become a stream
emissionis not idempotent: two calls are two facts, and it accepts no idempotency key — suppressing the second would lose data. There is no way to establish the outcome of a lost emit, and none is wanted: loss tolerance is declared in advance, for every call at once
the eventsare the history: the module keeps none of its own

Errors

  • An undeclared type, and a field that does not match the type's schema, are validation refusals — neither is a runtime surprise, because a type reaches the admin console from its declaration.
  • An oversized event is a validation refusal; fields are never clamped silently.
  • The rate exceeded answers in the rate-limit category, carrying the time before which a retry is pointless.
  • A sampling drop is not an error, and neither is an admissible loss. The call was executed and the drop is declared behaviour; reporting either as a failure would make declared behaviour indistinguishable from a fault.
  • A batch is whole or per element, and which one is declared — never "whatever happened".

Limits

Each ceiling names its behaviour at the edge; the numbers behind them land with the platform limits chapter.

  • Event rate per actor — a rate-limit refusal with a time. Exceeding the rate never drops silently: either that refusal, or a declared sampling drop, and there is no third outcome.
  • Size of one event — a validation refusal, never silently clamped fields.
  • Batch size — rejected before sending rather than applied partially.
  • Declared types per project — a new declaration is rejected at deploy time, not at runtime.
  • Fields in a type — the same, at deploy time.
  • Retention period — once it expires the event is unavailable by the declared period.

User flow

The killing blow becomes a typed telemetry event, sampled by its declaration and counted later. The ability and the stat that carry it are entity presets, not modules.

Beyond the SDK

Not translated yet — showing English.

The Operator Plane

What is deliberately not in the SDK. Project and environment lifecycle, deploy and rollback, billing, org and user administration, cluster routing — these belong to the admin panel, the CLI and the MCP surface, not to game code. The one deliberate exception is Schema as Code: schema is a developer surface, so it is in the SDK.

One model, two planes

The SDK planeThe operator plane
Reached fromgame codethe Control Panel, the CLI, MCP
Holdsrooms · entities · players · commerce · leaderboardsprojects & environments · deploy and rollback · billing · org and user administration · cluster routing
Works indeclarations, hooks, events, operationsthe panel's own screens

They share one model: the declaration you push is the one the panel renders. What does not cross the line is authority — game code cannot deploy, bill, or move a tenant.

Every SDK surface — each module, and the presets declared on entities — has an operator counterpart in the Control Panel, where the same declarations are viewed and edited from the other side:

SDK surfaceThe operator sees
Schema / Dataentities, migrations, the record browser, saved views, import/export
Entitystate machines, per-instance inspection
Entity Presetsdrop tables, ability, stat and projectile definitions, world-object presets — live-tunable
Accessthe role grid: roles × operations, row filters, column masks
Extensibilityscenario chains with overrides, resolved order, invocation traces
Roomsthe fleet: rooms, tick health, placement, drain status
Matchmakingqueues, tickets in flight, relaxation curves
Commercecatalog, storefront scheduling, receipts, refunds
Leaderboardscycles, record correction (audited), submission rates
Authproviders, sessions, bans, the auth scenario
Filesassets, UGC review queues, quotas
Analyticsdashboards, forwarders, ingestion lag
Mapmaps and obstacle sets, live instances
Visibility / Collision / Locomotion / Predictionper-room tuning: rules, response pairs, windows, per-actor packet cost
Groups / Messaginggroup browser, templates, moderation filters, schedules
Botsprofiles, fill quotas, brain endpoints
Inventory / Profileholdings and transfers, views and owned-entity sets

The design rule. An SDK capability without a panel surface is invisible to live ops; a panel surface without an SDK capability is a lie. Modules ship both halves together, and a declaration authored in either place is the same model in both.

One declaration's trip: the schema-author writes it, playserv push carries it, the panel renders it for the operator, and the retune lands in rooms that are already running.

Agent access

Everything the panel shows is also reachable by tooling: the platform exposes an MCP surface (the same API the panel uses), so AI agents and scripts operate projects (bootstrap, schema, records, players, deploys) under the same access model as any other actor.

Beyond the SDK

Not translated yet — showing English.

Under the hood: transport & the hub

An architecture reference, not a surface you call. Nothing on this page appears in the API you write against: there is no socket to open, no channel to pick, no envelope to fill, no retry to schedule. Core Concepts names the stack; the mechanism lives only here, so that an architect can check what the SDK does with a dropped connection, a disabled module or a message that must arrive exactly once.

The layer stack

The runtime stack from your code down to the transports, with the line below which you never call anything

Five layers, top down: user space, modules, primitives, the hub, and the transport adapters under it. The top two are user space; everything below is the SDK's own business.

  • User space is your code. It sees modules, and the vocabulary stops there.
  • Modules are the applied layer: rooms, matchmaking, inventory, leaderboards. They form a graph, not a tree, which is what the hub has to resolve when one of them is switched off (Inheritance & Composition is the shape itself).
  • Primitives are the first implementation everything references: data, events, RPC, groups. A module is a named assembly of primitives plus its own rules.
  • The hub is the controller: dependency injection, module mounting, the user session, state recovery, message quality of service, and routing each inbound message to the module mounted for it.
  • Transports are adapters onto a protocol. Several exist; the hub treats them alike.

Transports are adapters

There will be more than one transport, and they differ in ways that would otherwise leak into every module:

AxisRange
Shapemessage-driven or request-driven
Channelssingle-channel or multi-channel
Statewith connection state recovery, or without
ProtocolTCP or UDP

Today that means WebSocket, the PlayServ UDP transport, and plain HTTP. Each one is an adapter behind its own implementation details, and each exposes the same thing upward: a transport session. The hub holds a session, never a socket, so nothing above the adapter reasons about the network interface.

A declared boundary. One transport channel and one transport session at a time. Running a backend transport for leaderboards while a master-client transport carries the live session is out of scope, and the API does not promise it — no signature is only meaningful with several channels open. Whether a later version opens it is settled with the invariant surface per project; until then the single-session shape is the contract.

The hub hides the transport completely

Downward, the hub speaks the transport interface. Upward, it offers state, events and the user session. Module code and game code are equally unable to tell which transport is underneath, or how the hub batched a call, or what it did to get back to a consistent state after a gap.

  • The user session belongs to the hub, not to a module. Reconnect, resume and state recovery happen once, at the hub, for everything mounted on it.
  • Message QoS belongs to the hub, not to the data module. Envelopes, retries and packing are hub mechanics.

Message QoS — exactly three levels

A module declares only the delivery guarantee it needs:

LevelMeaning
at least onceredelivered until acknowledged; the receiver tolerates duplicates
at most oncesent once, never retried; loss is acceptable
exactly oncededuplicated and acknowledged; the expensive one, used where it is required

That declaration is the whole conversation about delivery; how the guarantee is met is neither the module's business nor yours.

DI, mounting, and disabled modules

The hub instantiates modules and mounts them — at the root or in a namespace — resolving each module's dependencies on the primitives and on other modules. A build that does not need a module does not mount it. Mounting is namespaced, and a second module claiming an already-occupied mount point is rejected at mount time — composition fails there, never at the first call into it.

Because modules form a graph, switching one off has consequences downstream, and the hub takes exactly one of two paths:

  1. Disable the dependent chain. Every module that needs the missing one is switched off too, and its interfaces are absent rather than failing.
  2. Declare degraded functionality. The dependents stay mounted and announce what they can no longer do.

There is no third path. Silent half-working — a mounted module quietly dropping the operations it can no longer perform — is the failure mode this rule exists to prevent, and it is why a disabled dependency is observable rather than mysterious.

The line this page sits under

Every promise on the module pages is kept above it: an entity mutation is the network operation, a hook is a typed function, a join is one call. The stack's names may reach you — Core Concepts points here — but nothing below the line is a call your code makes. Those layers exist so the promises above survive a transport change.

What you will meet — the delivery context your handlers run on, when a handle ends, and the in-memory implementation you test against — is one page up: Threads, Lifetime and Testing.

Start here

PlayServ SDK

El backend del juego que sale junto con la jugabilidad. PlayServ es un backend-as-a-service para juegos en vivo: un estudio opera el backend de su juego — datos, jugadores, Rooms, Matchmaking, comercio — sin hospedarlo. El SDK es la forma en que tu código, en el servidor y en el motor, trabaja con esa plataforma.

Esta página es la lista corta de lo que aquí es realmente distinto. Todo lo de abajo se decide una vez por proyecto y se configura en vez de escribirse; lo que llamas vive en las páginas de módulo, y cada sección de aquí termina nombrando la página que la posee.

One declaration you write, and the five things that happen with no further code from you

La simulación no es tu código

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Rooms, Collision, Locomotion, Prediction y la sincronización corren todos dentro de la plataforma. Tu juego son Declarations (Entities, mapas, habilidades, tablas de drop, política de sincronización), Hooks (tus reglas, llamadas en pasos con nombre), Events (suscríbete, no consultes en bucle) y Operations (lo que pides o lo que ordenas). Cada página de módulo está organizada exactamente alrededor de esos cuatro.

Lo que te ahorras es concreto — un bucle de juego, el ensamblado de snapshots, un codificador de Delta, la resolución de colisiones, la integración del movimiento, el manejo de reconexiones, la validación de impactos con compensación de lag. Consulta Getting Started, que construye exactamente eso.

Mutar estado declarado es la llamada de red

No hay envío. Declaras cómo se sincroniza un campo — un atributo al lado del campo — y cambiarlo ya es la operación de red: Deltas contra el último estado confirmado, el aspecto como unidad de política, prioridad y tasa de envío, la ventana retenida, Hooks antes y después del cambio. Nada río abajo lo escribes tú.

El mismo movimiento vale para todo lo demás que se declara: un Event, un RPC, un Group, un eje de Leaderboard. Una Declaration es la entrada de la API tipada, del panel administrativo que la dibuja y de la generación de código para cada binding — y por eso lo que versionas es la Declaration, no el código generado. Consulta Data & Subscriptions y Schema as Code.

Las interfaces siguen al Actor, no al lado

No hay SDK de cliente ni SDK de servidor. Se publica un solo SDK, y lo que una llamada puede hacer lo decide el Actor que hay detrás: un jugador, un servicio, un cerebro de bot, un operador.

El caso para el que está pensado es la máquina de un jugador que crea una Room y luego la ejecuta: un master-client, que ostenta las interfaces de room-owner y nada más. Un build de room-visitor no tiene kick ni close — no deshabilitados, ausentes.

Los derechos se componen de permisos atómicos, así que no hay niveles de rol integrados, y un rol restringe los datos hasta la fila y la columna. Consulta Authority, y Access & Roles para saber cómo se escribe una concesión.

Un diseño, estrechado dos veces — sobre una sola pila

Cómo está escrito

El SDK es un solo diseño con dos salidas de estrechamiento, y el orden es la regla: nada baja un nivel hasta que el nivel de arriba genuinamente no puede cargarlo. Los principios comunes son idénticos en todos los bindings. La forma de un lenguaje se queda solo con lo que su paradigma no puede expresar de la manera común — C# tiene atributos, Python tiene decoradores, la misma declaración escrita como cada lenguaje ya escribe esa idea. La forma de un motor se queda solo con lo que un motor remodela encima de su lenguaje: en Unreal una declaración viaja dentro de la macro de reflexión del propio motor, y el C# de Unity tampoco es el C# del servidor.

Cómo se ejecuta

El código de tu juego se dirige a módulos y a nada más. Los módulos se ensamblan a partir de cuatro Primitives — Events, RPC, Data y suscripciones, Groups. Debajo de ellos está el hub al que nunca llamas: inyección de dependencias, montaje de módulos, la sesión, la recuperación de estado, la calidad de servicio de los mensajes. Debajo de eso, los adaptadores de transporte, uno por protocolo, y cuál de ellos lleva una llamada no es algo que tu código decida ni note.

Ambas mitades completas: How the SDK Is Built, que termina en Under the Hood.

Los módulos se componen; nada hereda

No hay módulo base del que derivar ni jerarquía que extender — los módulos forman un grafo, porque un árbol solo admite ramas y las funcionalidades reales las cruzan: el matchmaking reserva asientos en Rooms, los drops colocan objetos a través del mapa, un chat vive dentro de una Room.

«Herencia» cubre aquí cuatro mecanismos distintos, y vale la pena distinguirlos: Los RPC de una Entity son parte de la Entity y no existen en ninguna otra parte. Un preset es un paquete nombrado de aspectos, no una clase base. Sobrescribir un paso de la plataforma es un atributo sobre tu reemplazo. Un módulo toma prestado a otro a través de un decorador que estrecha la interfaz prestada. Consulta Inheritance & Composition.

Quién ve qué se declara, no se filtra en el cliente

Con cuarenta jugadores un snapshot de la Room entera está bien; con doscientos no lo está, y el arreglo no es un tubo más ancho. Las reglas de interés deciden quién recibe qué porción, y los paquetes por Actor y el broadcast son dos modos de entrega de un solo modelo declarado — moverse entre ellos es configuración, no una reescritura. Las cosas lejanas se degradan a través de niveles de detalle declarados antes de desaparecer.

La parte que es una propiedad de seguridad y no de ancho de banda: el estado que no debe filtrarse nunca se envía. La niebla de guerra y los campos solo-del-dueño están ausentes del paquete, no escondidos en el cliente. Espectadores, admins y repeticiones obtienen su vista más amplia ostentando una concesión más amplia — autoridad otra vez, no un caso especial. Consulta Visibility.

Qué sobrevive a la pérdida de un host

Que un host muera no termina el partido. El estado de la Room no se copia entre hosts mientras se juega — un único dueño es lo que mantiene el ordenamiento fuera del consenso — y lo que en cambio hace sobrevivible el partido es que el estado está declarado, y el estado declarado se guarda fuera del host. Se toma una instantánea en un intervalo declarado, y un reemplazo retoma desde la última.

Así que el reemplazo tiene el estado entero — pero al momento de esa instantánea. Completo, no actual. Lo que cuesta es el juego desde la última instantánea; lo que no queda cubierto es todo lo que guardaste sólo en actores del motor. Un deploy usa el mismo mecanismo, menos la pérdida: drenar un host es la ruta de failover ejecutada a propósito. Consulta What Survives Losing a Host, y Rooms para la ventana de gracia por la que un jugador vuelve a entrar.

Cualquier paso de la plataforma puede ser tuyo

Cada escenario de la plataforma es una cadena de funciones registradas, y tú reemplazas un eslabón o lo envuelves. Sign-in, validación de entrada, compra, envío, subida — cada uno es un paso con nombre, y tu reemplazo se declara con un atributo, con versiones elegidas por condición y el paso propio de la plataforma como respaldo.

Esto es lo que significa «plataforma personalizable» en concreto, y es lo que está en lugar de entregarte nuestro código fuente: reemplazas los pasos en vez de hacer un fork de aquello que los ejecuta. Consulta Extensibility.

Una superficie, seis lenguajes

Un contrato, seis proyecciones: C#, TypeScript, Python y Go en el servidor; C++ de Unreal y C# de Unity generados en el motor. Cada muestra de código de este sitio enseña las seis, y donde un binding no tiene superficie para un paso la pestaña nombra la razón en vez de fingir — el paso corre fuera del motor, u otro Actor ostenta el derecho a hacer esa llamada.

Dos consecuencias que vale conocer antes de elegir lenguaje: el RPC toma objetos del SDK por referencia en vez de DTO aplanados, y las primitivas asíncronas son parte del núcleo en vez de estar atornilladas encima — Channels, Streams y direccionamiento por Group, de modo que puedes hablarle a un Group entero y recoger las respuestas, o consumir un archivo por trozos mientras todavía se está subiendo.

También hay un host determinista en memoria que ejecuta el código de tu juego sin backend detrás y con el tiempo bajo tu control, así que una prueba es una prueba y no una condición de carrera — consulta Threads, Lifetime and Testing.


Lo que deliberadamente no está en el SDK — deploys, facturación, administración de organización y de usuarios — vive en el plano del operador. La barra lateral es el mapa de todo lo demás; Getting Started es el camino más corto para entrar.

Start here

Primeros pasos

Una arena jugable (mapa, tanques, disparos, drops), declarada de punta a punta. Nada de lo de abajo es un bucle de juego: la simulación corre dentro de la plataforma, y esto es todo el código que hay.

Antes de empezar. Un Project con un Environment dev (creado en el plano del operador, que es dueño de ese ciclo de vida), la CLI playserv autenticada contra él, y el paquete del SDK para tu binding — nada más se instala en tu juego.

Flujo del usuario

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

Cada llamada que haces tú es uno de los ejemplos de abajo; los pasos intermedios son la plataforma actuando sobre lo que dijo una Declaration. La ability, el Stat y la drop-table de la figura son entity presets — Declarations sobre Entities, no módulos propios.

1. Declara el mundo

Las Entities son tu schema más sus aspectos vivos. Un atributo por comportamiento, al lado del campo que describe:

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

Mutar un campo [Sync] es la operación de red. No hay snapshot que ensamblar ni llamada de envío que hacer.

Ninguno de esos tipos lo defines tú, y cada uno pertenece a una página:

En el bloqueViene de
Vector3, Statel paquete core de tu binding
Body, y las formas de cuerpoCollision
Motion, y los cinco modelos de movimientoLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectlos entity presets que los usan
EntryRequest, Verdict, StatEventpayloads de Hook, entregados por el módulo al que te enganchas
SeatMatchmaking
Scope, los alcances de sincronizaciónVisibility
Tick, las tasas de TickRooms

Los enums son cerrados. Una regla que ningún miembro cubre se escribe como un predicado en vez de como un miembro nuevo: [Aspect("loadout", Visible = "owner == caller.player")] es como se expresa la visibilidad por campo cuando Scope.Owner no es exactamente la regla que querías (Data).

2. Declara la Room

Un template de Room dice qué es una sesión, y nombra las Declarations de las que se sirve. No hay clase de Room que heredar ni método de Tick que rellenar, porque el interior de la Room es de la plataforma:

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

Los ids entre comillas son claves de contenido, no texto libre:

ClaveQué nombra
rockun prop del conjunto de obstáculos del mapa (Map)
ammo.shell, railgunítems del catálogo (Catalog & Commerce) — que es también como el disparo descuenta munición y el pickup cae en una bolsa
battle, arena, crate-loot, shelllas claves que registran estas cuatro Declarations

playserv push rechaza una Declaration cuya clave no existe en el Environment al que apunta, así que una clave mal escrita falla en el deploy y no en el primer Cast. Viva donde viva el template, sigue siendo reajustable sin volver a desplegar el motor: el modelo enviado es lo que live-ops edita en el panel.

3. Escribe tus reglas como Hooks

Los Hooks son funciones en la nube que la plataforma llama en pasos con nombre. Tipado a la entrada, tipado a la salida — sin bolsas de contexto, sin loggers en la firma:

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Una compuerta Before puede rechazar el paso; un observador After corre una vez que el paso se confirmó y no puede rechazarlo. Así que un pack de bienvenida que no llegó cuesta 20 proyectiles, no el sign-in. Extensibility tiene el resto.

Casi nada de ese bloque hace el trabajo que aparenta hacer:

La líneaQué la ejecuta realmente
se dispara Stats.Depletedel Stat llegando a su piso — 0 para Hp, ya que la Declaration solo fijó Max
la transición de muerteAtMin = "death" en la sección 1; el Hook agrega la consecuencia, no la transición
el daño[Effect(Damage = 35)] en el proyectil, aplicado por la plataforma al impactar
RollAtgenerado sobre la Declaration [DropTable] — por eso es partial, y por eso la pestaña de Go dice drops.RollAtCrateLoot
el pickuppasar por encima del botín transfiere los ítems al inventario del jugador de forma atómica; esa transferencia es el evento changed que un HUD dibuja

Tipos generados para el motor

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

Ejecútalo (o deja que lo ejecute CI) después de cada push de schema — los tipos se regeneran, nunca se editan a mano, y el Tank generado es el Tank enviado. playserv push lee un proyecto de motor exactamente igual que lee un proyecto de servidor: los especificadores de UHT y los atributos de C# son la Declaration, así que apuntar la CLI al proyecto de UE o de Unity es todo el paso de exportación. En qué hilo aterriza un callback, y cuándo termina una suscripción, quedan fijados por el modelo de runtime — Threads, Lifetime and Testing.

4. Conecta un cliente

La API de cliente es simétrica: los mismos módulos, y lo que un build puede llamar lo decide la clave bajo la que corre. Un build de motor lleva una clave de jugador — aquí projectKey, la credencial para un Project y un Environment, emitida en el panel y embarcada dentro del build. No nombra roles: los roles se resuelven del lado del servidor en cada petición, y el jugador que hay detrás llega con SignIn. Los bindings de motor son de primera clase aquí; los bindings de servidor manejan la misma superficie sin interfaz gráfica (un cerebro de bot, una prueba de carga, una herramienta de operaciones):

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

Cuatro llamadas, y cada una responde algo distinto:

LlamadaCon qué responde
Findun ticket colocado, y un asiento reservado en una Room. La reserva se mantiene el término que declara el template; dejarla vencer cuesta el asiento, no el derecho a jugar
Joinresuelve una vez que llegó el estado actual de la Room. El Tank que el template hace aparecer para un miembro que entra es parte de ese estado, así que My<Tank>() responde en la línea siguiente y todo lo que va después de Join es tráfico en vivo
Castdisparar es el verbo de la habilidad, no una segunda superficie: una Declaration [Projectile] es una habilidad con balística encima, así que Cast verifica enfriamiento, munición y objetivo igual que lo haría para un dash o una curación (Entity Presets)
Abilitiesgenerado — el codegen recoge las habilidades y los proyectiles declarados en un tipo por binding

Los rechazos llegan como el Problem tipado de la plataforma — un código más una razón para un humano. C#, TypeScript, Python y Unreal lo lanzan; Go lo devuelve como valor de error, y por eso cada llamada de esa pestaña está comprobada. Un servidor dedicado de Unreal o un master-client corre el mismo binario bajo una clave de host, y sus roles llevan las filas mc: Rooms → Hospedar una Room es esa superficie de punta a punta, Access es donde se declaran las claves y los roles que hay detrás.

5. Publica y juega

playserv push          # schema + declarations + hooks, one deploy
playserv open battle   # a dev-env room, live in the panel

playserv push escanea el proyecto en el que corre — Hooks por atributo y Declarations — y despliega al Environment al que apuntas (--env dev por defecto). playserv schema push a secas mueve solo el modelo, y playserv schema diff es contra lo que se compara un push (Schema).

Un push aterriza entero o no aterriza, y se rechaza en vez de fusionarse si el schema desplegado se movió desde tu diff. Un cambio que rompería datos existentes no viaja en el push en absoluto: se convierte en una migración que lees primero y después ejecutas o cancelas (Schema). Mover un modelo de dev a prod es un acto del plano del operador y no una llamada del SDK (el plano del operador).

playserv open battle crea una Room a partir del template battle enviado y la abre en el panel, donde el estado de la Room y sus miembros son inspeccionables mientras juegas contra ella. El panel ahora muestra el template, el mapa, la drop-table y los Hooks: el mismo modelo que escribiste en código, editable también ahí.

Los números que no elegiste

Capacity = 8, Hz30, Hz = 10 y Cooldown = 1.5f son el ajuste de este juego, no techos. Los límites propios de la plataforma están por encima de ellos, y cada uno se declara con lo que quien llama observa en la frontera:

En la fronteraQué recibe quien llama
una entrada por encima de la capacidad, o a una Room cerradaconflict — vale reintentar cuando se libere un asiento
creación de Room por encima del límite por Project o por Actorrechazada, y nada de lo ya creado se descarta
crear Rooms o hacer sign-in demasiado rápidoun rechazo por límite de tasa que lleva el tiempo de espera
un payload de Event por encima del tope de la Roomrechazado antes de enviarse, nunca truncado
una lectura por encima del techo de filas de un rollas filas que quepan en el techo, más la marca que dice que fue cortada

Los números en sí son por Environment y llegan con los límites de la plataforma; el comportamiento en la frontera no los espera (Rooms, Access, Auth).

Adónde ir después

  1. Ejemplos, la sección justo después de esta: un leaderboard en Tanks, cajas de vida en Tanks, o la receta del torneo diario para el bucle meta — una funcionalidad real cada uno, con cada paso enlazando a la página de módulo dueña de lo que acabas de usar.
  2. Cómo funciona el SDK, cuando su forma empieza a importar más que la siguiente funcionalidad: Core Concepts es el diccionario, y cuatro artículos responden quién está llamando (Authority), cómo se escribe una concesión (Access & Roles), de qué está hecho el SDK (How the SDK Is Built) y cómo se ejecuta (Threads, Lifetime and Testing).
  3. Después, los módulos. Cada página de módulo tiene la misma anatomía — tesis, actores, cuándo usarlo, flujo del usuario, ejemplos, modelo — así que la segunda se lee más rápido que la primera y la quinta toma minutos. Entity y Data son las dos en las que se apoya todo lo demás.

Rutas de lectura por rol

Sea cual sea tu rol, lee primero Authority — un solo SDK y una concesión por Actor es el prerrequisito compartido — con Core Concepts abierto al lado.

Tú eresLee, en orden
Dev de cliente de juego (Unity · cliente Unreal · TS)Auth → Matchmaking → Rooms → Entity → Data, luego por funcionalidad: Inventory · Leaderboards · Messaging · Profile
Dev de servidor (C# · TS · Python · Go)Schema → los bloques de construcción → Entity → Extensibility → Access, luego los módulos cuyas Declarations posees: Rooms · Matchmaking · Leaderboards · Commerce
Dev de servidor dedicado de UnrealRooms (Hospedar una Room) → Bots → Locomotion · World Objects → Map → What Survives Losing a Host
Examples

Un leaderboard en Tanks

Tanks, la arena de ejemplo de Getting Started, no tiene leaderboard. Esta lección, que puedes tomar en cualquier momento después de Getting Started, agrega un tablero semanal de bajas en tres pasos: declara el tablero, envía desde el Hook de muerte, léelo en el cliente. Cada paso enlaza a la página de módulo dueña de lo que acabas de usar, así que la lección enseña señalando en vez de repitiendo.

Paso 1 — declara el tablero

Un tablero es una Declaration: qué campo lo ordena, cómo se combinan los envíos repetidos, cuándo se reinicia y quién puede enviar. Aggregation.Increment suma cada envío al total acumulado, así que una baja es un punto. Submit.ServerOnly es el valor por defecto y cierra el tablero a los clientes, que es lo que hace del paso 2 la única entrada.

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

Publícalo con playserv push y el tablero aparece en el panel, vacío, con su ciclo del lunes ya programado — lunes 00:00 UTC, ya que las programaciones son en UTC. Los ejes que no fijaste conservan sus valores por defecto. Consulta Leaderboards para la lista completa de ejes — dueño, clave de orden, campos de visualización, reglas de torneo.

Paso 2 — envía desde el Hook de muerte

Tanks ya termina una vida a través del umbral de HP declarado sobre el tanque: con HP en cero se dispara la transición death y la plataforma llama al Hook después de ella. El Hook es una función en la nube, tipada a la entrada y a la salida, así que enviar una baja es una línea dentro de él.

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By es el atacante que llevaba el daño, así que ninguna contabilidad rastrea quién le disparó a quién. e.Id es el id del propio Event, y pasarlo como clave de idempotencia es lo que necesita un tablero Increment: un evento de baja reentregado cuenta una vez, no dos. El punto de Hook, las garantías de orden y el contrato de veto son Extensibility; el umbral que lo dispara es un preset de Stat sobre el tanque, y la fila de puntaje que escribe son datos corrientes que puedes consultar.

Paso 3 — lee el tablero en el cliente

Dos lecturas cubren toda la interfaz: la cima del tablero y la ventana alrededor del jugador local — cinco filas arriba, cinco abajo, más la tuya. Ambas vuelven como entradas ordenadas con bajas y nombre visible, listas para enlazarse a una lista. Una suscripción mantiene el panel al día mientras corre la partida, y entrega únicamente el puesto del jugador local.

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

El reinicio del lunes cierra el ciclo en vez de borrarlo, así que la tabla de la semana pasada sigue siendo legible por su etiqueta — la misma llamada Top con un argumento cycle:. Un Hook de premio al cierre del ciclo es el cuarto paso natural, descrito en Leaderboards.

Adónde ir después

  • Leaderboards — los ejes, los ciclos, los torneos, y el Hook previo al envío que recorta puntajes sospechosos.
  • Extensibility — cada punto de Hook, en orden, con el contrato de veto.
  • Entity presets — el umbral de Stat que disparó la baja del paso 2.
  • Cajas de vida en Tanks — el otro ejemplo de Tanks: dos Declarations y un Hook.
  • Getting Started — la arena de Tanks que esta lección extiende.
  • Core Concepts — el vocabulario que da por supuesto cada página de módulo.
Examples

Cajas de vida en Tanks

Antes
un tanque que recibe daño queda dañado hasta que muere. No hay vuelta atrás, así que cada pelea es una cuenta regresiva y la arena no da razones para recorrerla.
Después
aparecen cajas de vida por la arena, separadas entre sí y lejos de quien esté peleando. Pasarles por encima te cura. Nada más del juego cambia — y tampoco cambia el código de la Room, porque no hay ninguno.

Esta es la segunda lección de Tanks. Toma tres pasos y ningún módulo nuevo: una Declaration para la caja, una Declaration para dónde aparecen las cajas, y un Hook para lo que hace recoger una. Tómala después de Getting Started, en cualquier orden respecto de la lección del leaderboard.

Paso 1 — declara la caja

Una caja es una Entity con dos presets aplicados y un cuerpo que reporta el contacto sin detener a nadie. Response.Pass sobre la capa pickups es lo que la vuelve un pickup y no un obstáculo: el contacto se reporta, el movimiento pasa de largo.

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

Dos cosas que no tuviste que escribir: dónde se dibuja la caja (el cliente ya renderiza los objetos del mundo declarados) y cómo llega su posición a los clientes — [Sync] es la llamada de red.

Pertenece a Entity Presets y Collision.

Paso 2 — declara dónde aparecen las cajas

La colocación también es una Declaration, y este es el paso que decide si la funcionalidad se siente justa. La separación evita que las cajas se amontonen, la distancia a los jugadores evita que aparezcan en medio de un duelo, y la regla de no repetir evita que el mismo punto sea la respuesta todas las veces.

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

Las posiciones válidas vienen de Map — la tabla pide un punto en la capa ground y el mapa responde con uno realmente alcanzable, así que una caja nunca cae dentro de una pared.

Pertenece a Entity Presets y Map.

Paso 3 — cura al recoger

Un solo Hook, y es todo el código de la lección. Corre en la plataforma como función en la nube, y por eso no aparece en ninguna de las dos pestañas de motor.

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

Tres cosas que este Hook obtiene gratis, y cada una es la razón de que la lección sea así de corta:

  • El rechazo es tipado. Un tanque con la vida llena recibe already at full health con una razón que un cliente puede mostrar, y la caja sigue ahí para quien la necesite.
  • Ajustar un Stat es autoritativo del servidor. No es algo que un cliente pueda pedir, así que no hay excepción de anti-cheat que escribir para los pickups.
  • El HUD se actualiza sin que se lo digan. Adjust emite changed; el cliente ya está suscrito a los Stats declarados del tanque. No escribiste ningún mensaje de red.

Pertenece a Extensibility y Entity Presets.

Qué cambió, y qué no

AntesDespués
un tanque dañadoqueda dañado hasta que muerepuede recuperarse recorriendo la arena
código de Roomningunosigue sin haberlo
módulos nuevos montados—ninguno: dos Declarations y un Hook
excepciones de anti-cheat—ninguna: curar es autoritativo del servidor como cualquier cambio de Stat

Adónde ir después

  • Entity Presets — el generador de drops, los objetos del mundo y el modelo de Stat en los que se apoyó esta lección, los tres presets de entity y no módulos.
  • Collision — capas, respuestas, y la diferencia entre un contacto reportado y uno que bloquea.
  • Map — cómo se elige una posición válida, y qué significa «alcanzable».
  • Extensibility — cada punto de Hook en orden, con el contrato de veto.
  • Un leaderboard en Tanks — el otro ejemplo de Tanks.
Examples

Un torneo diario

Lo que obtienes: un torneo diario con una ventana de inscripción, Rooms sembradas y un pago de premios — construido enteramente con Declarations y Hooks sobre módulos de los que ya tienes página. Nada de aquí es un concepto nuevo; son leaderboards, matchmaking, Rooms, comercio y mensajería compuestos para un solo bucle meta.

Paso 1 — declara el tablero con ventana de inscripción y límites de intentos

Un torneo es una Declaration de leaderboard corriente más restricciones de participación: una ventana de inscripción, un tope de inscritos e intentos por ciclo. Nada del puntaje cambia — la clave de orden, la agregación y el reinicio quedan exactamente como en cualquier tablero.

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

Publícalo y el panel muestra un cuadro vacío con su ventana programada. joinRequired: true hace de los inscritos una membresía y no todo el que juega, así que un envío de alguien no inscrito es rechazado. Consulta Leaderboards para el resto de la lista de ejes y para lo que hace cada restricción en su frontera.

Paso 2 — se abre la ventana: una party entra, las Rooms se siembran

Una vez abierta la ventana de inscripción, los jugadores hacen cola exactamente igual que para cualquier partida: crean o entran a una party, y luego una sola llamada Find. El matchmaker coloca la party en un cuadro de torneo y Rooms siembra la partida — la misma ruta de colocación y asiento que usa cualquier partida, solo que acotada a la cola del torneo.

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

Los topes del paso 1 pertenecen al tablero, no al matchmaker: la cola coloca parties, y es el tablero el que se topa con un inscrito por encima de su tope o con un jugador pasado de intentos. El inscrito 65 de 64 es rechazado como conflicto sin desalojar a nadie, y un cuarto envío en un mismo ciclo responde «intentos agotados» — también un conflicto, que se resuelve con el reinicio diario y no pidiendo un permiso.

Paso 3 — los puntajes se envían por el Hook de descarte

Las Rooms no se autoinforman un ganador a un leaderboard; ese enlace es un Hook, con el mismo contrato de Extensibility que en todas partes — tipado a la entrada, tipado a la salida, sin bolsa de contexto. El Hook on dispose de la Room (Rooms) es lo último que corre con el estado final de la partida en la mano, y envía desde ahí.

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner y FinalScore son campos que declara en su estado el propio template de Room de este juego — la plataforma no agrega nada al snapshot (Rooms es donde se declara el estado del template). El Hook de descarte (Rooms.Disposed) entrega el snapshot final, así que la partida nunca se recalcula. El Hook previo al envío de Leaderboards sigue corriendo primero — un puntaje de cuadro está sujeto al mismo contrato de corregir-o-rechazar que cualquier otro envío.

Paso 4 — cierra el ciclo: se otorgan premios, se notifica al jugador

El reinicio diario del paso 1 cierra el ciclo exactamente como lo hace cualquier reinicio de leaderboard, y dispara CycleClosed llevando la etiqueta del ciclo cerrado — esa etiqueta es lo que hace que el Hook lea la tabla que acaba de cerrarse y no la vacía que acaba de abrirse.

Un solo Hook hace el resto: otorga el premio por la ruta de derechos de Commerce y empuja el resultado por Messaging, así que no hay trabajo de pago aparte que ejecutar. Una notificación se dirige a un Actor, así que el top 8 es un bucle de ocho, cada uno llevando su propio argumento rank al template.

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

Los números del paso 1 son valores seed: LiveOps reajusta la ventana, el tope de inscritos y la cuenta de intentos en el panel, y el siguiente deploy no sobrescribe el cambio en silencio. Convertir esto en un torneo semanal es una sola edición — Reset.Daily() pasa a ser Reset.Weekly(DayOfWeek.Monday), y los pasos 2 al 4 se quedan como están.

Adónde ir después

How the SDK works

Conceptos esenciales

Las palabras que el resto de estas páginas usa sin detenerse a explicarlas. Una página de módulo da por supuesto que ya sabes qué es un Actor, o un aspecto, o una Room — aquí cada uno recibe una definición de una línea y un enlace a la página donde vive de verdad el mecanismo que hay detrás. Léelo una vez antes de la referencia de módulos, o vuelve cuando una palabra resulte cargar más peso del que esperabas.

Tres cosas son demasiado grandes para una entrada y tienen página propia: Authority — quién está llamando, y qué decide eso por sí solo; How the SDK Is Built — de qué está hecho el SDK; Inheritance & Composition — cómo se apoyan los módulos unos en otros. En ese orden se leen como un solo argumento.

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

Las cuatro superficies

Todo módulo expone exactamente cuatro cosas, y toda página de módulo está organizada alrededor de ellas. Este es el modelo de programación:

SuperficieSignificado
Declarationsqué existe y cómo se comporta, escrito en código o en el panel administrativo; el mismo modelo de cualquiera de las dos formas
Hookstus reglas, llamadas por la plataforma en pasos con nombre; desplegadas como funciones en la nube
Eventslo que la plataforma te dice que pasó — suscríbete, no consultes en bucle
Operationslo que pides o lo que ordenas, desde una función o desde un cliente

Actor

Quién hace una llamada. Lo que una llamada puede hacer lo decide el Actor que hay detrás, nunca el build en el que se compiló el código — el argumento es Authority, el mecanismo (permisos atómicos, roles compuestos, InterfaceGrant) es Access & Roles.

Esta documentación toma los nombres de Actor de un solo catálogo — los presets que la plataforma publica. Es un conjunto de presets, no una lista cerrada (un Project nombra sus propios actores), pero cada línea actors de un esquema, cada fila de Quién-hace-qué y cada ficha de diagrama de flujo de estas páginas usa exactamente estas grafías:

player · backend-service · operator · host · moderator · schema-author · architect · bot-brain · room-owner · room-visitor · entry-validator · spectator · match-organizer · warehouse-keeper · seller — y any cuando una página se refiere a todos ellos.

Una página puede además introducir un rol de escena para un diagrama — un participante descriptivo como member o attacker — siempre que su propia prosa o su tabla de Quién-hace-qué lo introduzca primero.

Runtime surface

Dónde corre el código. Estas cuatro etiquetas se usan en todas las tablas de operaciones:

EtiquetaSuperficie
fnFunción en la nube (C# · TypeScript · Python · Go). Autoritativa del servidor; el hogar principal de tus reglas
clCliente de juego (C++ de Unreal / C# de Unity). API simétrica; los roles desbloquean menos
mcLa superficie de host de Room: un master-client (un cliente que es dueño de una Room) o un servidor dedicado de Unreal bajo su clave de host
admPanel administrativo / CLI / MCP — donde el SDK y el plano del operador comparten un modelo

Este es el eje que se confunde constantemente con el de arriba. Dónde corre el código y qué interfaz de Actor ostenta son dos preguntas distintas: el mismo código ostenta los mismos derechos donde sea que se lo coloque, y lo único que difiere es la concesión.

Project & Environment

Un Project es el backend de un juego, con un schema y sus datos en Environments aislados (dev, prod). Toda llamada corre dentro de un Project + Environment.

Entity

El sustantivo central. Una Entity es una Declaration de schema más sus aspectos vivos: datos 0..*, estados 0..*, RPC 0..*, Events 0..*, Hooks e historial de cambios. Un tanque, una puerta, una barra de Stat y una misión son todos Entities, y difieren solo en qué aspectos llevan. Las combinaciones habituales se publican como presets (GameObject, Stat, Character, Interactable, Projectile). Consulta Entity.

Expected state

Una solicitud de transición puede nombrar el estado que espera, y entonces es ese estado o un rechazo. Una solicitud que no nombra ninguno se evalúa contra el estado que tiene la máquina cuando la plataforma la procesa — nunca contra el estado del momento en que se envió.

La respuesta describe ese momento y no promete nada sobre lo posterior: que la transición de otro aterrice mientras la respuesta va en camino deja la respuesta verdadera y no la cancela. Así que nombra el estado esperado cuando el desenlace depende de lo que había antes, y si no, no leas la respuesta como un snapshot que sobreviva a la llamada.

Room

Una Room es una sesión de juego, no un lugar donde corre tu código. A la plataforma no le importa qué la hospeda: un servidor dedicado, un master-client, o el backend mismo. El interior de la Room es nuestro; tú conduces una Room desde afuera, desde funciones en la nube y clientes, mediante Declarations, Hooks, Events y Operations. Consulta Rooms.

Channel & Stream

Las primitivas asíncronas que están debajo de todo. Un Channel es un tema pub/sub direccionable: una Room, un Group, una Entity, o el tuyo propio. Un Stream es un flujo por trozos en cualquiera de las dos direcciones: los archivos se consumen a medida que llegan los trozos, las consultas pueden transmitirse, y un RPC puede repartirse a un Group y recoger las respuestas. Consulta Core.

Primitive

Uno de los cuatro ladrillos con los que se ensambla todo módulo: Events (declarar, emitir, suscribirse), RPC (invocar a través del cable), datos y suscripciones (mecánica de sincronización) y Groups (una lista, muchos oyentes). Una Room, un chat y un pool de matchmaking son el mismo Primitive de Group bajo reglas distintas. Si una funcionalidad no puede expresarse con los cuatro, eso es un defecto de diseño y no un argumento a favor de un quinto.

Hook contract

Un solo contrato en todas partes: un Hook before corre por delante de la validación, recibe el payload tipado, puede mutarlo o rechazarlo; un Hook after corre una vez que la operación se aplicó, recibe la solicitud y el resultado, y solo puede agregar efectos secundarios — nunca puede hacer fallar la operación. Los Hooks están ordenados; todo paso registrado de la plataforma puede llevarlos. Consulta Extensibility.

Delta & Revision

Los clientes reciben el estado como Deltas: solo los campos cambiados, codificados contra el último estado que el receptor confirmó. Todo registro lleva una Revision; las escrituras condicionales rechazan si no coincide. Un solo concepto de versionado sirve a la sincronización, a la concurrencia y al historial. Consulta Data. (Lo que Unreal llama replicación — qué cliente ve qué estado, y con qué frecuencia — vive aquí y en Visibility y Prediction. La página What Survives Losing a Host es la otra: qué máquina es dueña de una Entity, y cuál lo es después.)

Tick

Las Rooms simulan a paso fijo. Todo cambio de estado se sella con su Tick; la sincronización, la predicción, la compensación de lag y el historial cuentan todos en Ticks y no en reloj de pared. Los datos llevan su tiempo de evento verdadero — eso es lo que hace exactos el rebobinado y la reconciliación. Consulta Prediction.

How the SDK works

Authority

La autoridad es una abstracción, no dos builds del SDK. No hay SDK de cliente ni SDK de servidor. Hay un solo SDK, y lo que una llamada dada tiene permitido hacer lo decide el Actor que la hace.

Un master-client no es ni un cliente ni un servidor

La máquina de un jugador que crea una Room y luego la ejecuta — un master-client — ostenta las interfaces de room-owner y nada más. No es un servidor: no puede hacer todo lo que un servidor puede. Tampoco es un cliente corriente.

Un servidor dedicado es la misma figura desde el otro lado: el mismo cliente sin la renderización, y no necesita un SDK aparte. Lo que separa a los dos es la confianza, no la construcción, y la confianza la lleva la concesión.

Las interfaces siguen al Actor, no al lado

Un módulo no expone «la API de cliente» y «la API de servidor». Expone lo que puede hacer un room-owner, lo que puede hacer un entry-validator, lo que puede hacer un seller. Cliente y servidor son plomería; los actores son el dominio. Dentro de cada página de módulo la superficie se agrupa igual — esto es para estas necesidades, aquello para aquellas.

Un rol es el derecho y la clasificación, ambos

Aquí hay exactamente una dimensión. Un rol lleva lo que un Actor puede hacer, y es también la forma de decir a quién va dirigido algo. Deliberadamente no agregamos un segundo eje de etiquetas o rótulos junto a él: una cosa que declarar, una cosa que comprobar, una cosa que leer en el panel administrativo.

whoami es cómo lo pregunta el código. Reporta el Actor y las interfaces que ese Actor desbloquea ahora mismo — no una lista estática horneada en el binario en tiempo de build.

Dónde corre el código y qué Actor ostenta son preguntas distintas

PreguntaRespuestas
¿Dónde se ejecuta este código?una función en la nube · un cliente de juego · un master-client o un host de servidor dedicado
¿Qué interfaz de Actor ostenta?player · room-owner · entry-validator · seller · moderator · backend-service · …

Dispuestos como una cuadrícula, los dos ejes son independientes y toda celda es alcanzable:

función en la nubecliente de juegohost de Roomadmin
player✓✓✓—
room-owner✓✓ — la máquina propia de un jugador, hospedando✓—
backend-service✓—✓✓

La celda resaltada es código corriendo en un cliente y haciendo el trabajo de un servidor. Tiene nombre — room-owner — y es una concesión como cualquier otra.

Cualquier combinación es legal. Una función en la nube no es automáticamente privilegiada, y un cliente no está automáticamente limitado: los derechos vienen de la concesión, y la concesión se declara. Los derechos del código son los mismos donde sea que corra — lo único que difiere es lo que se le concedió.

La revocación surte efecto sin reemitir la credencial

Una credencial nombra una identidad. No lleva una lista de roles. Los roles se resuelven del lado del servidor, por petición, lo que significa que el cliente nunca tiene la prueba de sus propios permisos y no hay nada obsoleto que seguir presentando después de una revocación.

Dos consecuencias, cada una dicha donde le corresponde:

  • Revocar un rol surte efecto sin volver a emitir la credencial — consulta Otorgar un rol.
  • Se vuelve observable a más tardar en el límite de obsolescencia declarado de la caché de derechos. No prometemos que sea instantáneo.

Dónde se declara la autoridad, en vez de inferirse

  • Un tipo de Room declara su modo de autoridad, y no hay valor por defecto: o bien nuestra simulación ejecuta el Tick, o bien lo hace una autoridad externa — el servidor de juego del estudio, o el cliente de un jugador como master-client. Eso es Quién ejecuta el Tick.
  • Hasta dónde se confía en una autoridad externa respecto del desenlace es una Declaration aparte sobre el tipo de Room — aceptarlo, comprobarlo con un Hook, o no aceptarlo. Otra vez sin valor por defecto.
  • Qué credencial resuelve a qué rol, y qué es una clave de host, es Access & Roles.
How the SDK works

Access & Roles

Los roles se componen, nunca se codifican a mano. Los permisos atómicos se componen en roles; los roles restringen los datos hasta la fila y la columna, y deciden qué interfaces de módulo llega siquiera a ver un build. Esto reemplaza la división de claves cliente/servidor: una credencial nombra una identidad, y sus roles se resuelven por petición.

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

Cuándo usarlo

  • Necesitas una credencial más estrecha que «cliente» o «servidor» — detrás de ella se resuelven roles compuestos en cada petición.
  • El acceso a los datos debe detenerse en filas y columnas: alcance por región, máscaras de PII, contratistas de solo lectura.
  • Un build solo debería ver las interfaces que su rol desbloquea — kick/close sencillamente no está ahí para un visitante.
  • Tu interfaz debe atenuar botones con honestidad — CanI evalúa la misma política que el servidor va a aplicar.
  • Sáltatelo cuando los presets publicados (player, room-owner, seller, …) ya coincidan con tus actores — todo módulo los respeta por defecto; el catálogo completo vive en Core Concepts.

Quién hace qué

ActorEn esta página
operatordeclara roles y políticas, fija límites de fila/columna, otorga roles, emite claves
match-organizerel personal de torneo del flujo de abajo: lleva una clave compuesta, controla las entradas, no puede reembolsar
every actorcomprueba CanI antes de actuar; ve solo las interfaces que tiene desbloqueadas

De un vistazo

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

Un átomo es un par — un recurso y uno de cuatro verbos: leer, escribir, ejecutar, administrar. El conjunto de verbos es fijo, y un caso que no encaja parte el recurso en vez de hacer crecer la lista. Por eso controlar la entrada de otro es Rooms.Membership.Administer y no un verbo ValidateEntry propio: actuar sobre la membresía de otro Actor es administración, mientras que entrar uno mismo es Rooms.Membership.Write sobre el mismo recurso.

El modelo

La ACL de datos es rol × operación × predicado de fila × máscara de campos — un solo modelo, idéntico tanto si se escribió en código, como por la API, o en la grilla de roles del panel.

Con qué está construido el acceso.

TérminoQué es
atomun par recurso × verbo. Los cuatro verbos son read (traer, seleccionar, suscribirse), write (crear, cambiar, borrar, y actuar por uno mismo — entrar, salir), execute (invocar una función, aplicar una ability) y administer (actuar sobre otros: expulsar, cerrar, forzar un cambio de estado)
roleun conjunto de átomos con nombre. Puede incluir otro rol, y un ciclo en la inclusión es un error de configuración y no algo que se resuelva en runtime
role presetse publica sobre los átomos y sigue funcionando sin cambios para consumidores ya desplegados. Un punto de partida, no una restricción: un Project declara roles propios a partir de los mismos átomos
row predicatequé filas — un predicado booleano sobre valores de la sesión
field maskqué campos, declarado por rol y operación. Un campo que un rol no puede leer no se devuelve en absoluto, en vez de devolverse vacío

A qué resuelve una credencial.

CredencialQué desbloquea
player keyla lleva un build de motor; el jugador que hay detrás llega con el sign-in, y el build ve las filas cl de toda tabla de operaciones
host keyla lleva un servidor dedicado o un master-client, y sus roles desbloquean las filas mc
pushed codecorre con el rol backend-service del Project — eso es lo que comprueba el Authoritative = true de un leaderboard
a registered hookno otorga nada extra: tu función conserva el rol bajo el que se desplegó

La leyenda de etiquetas (fn / cl / mc / adm) pertenece a Core Concepts.

Qué vale para toda comprobación.

SiempreQué es
a credentialno lleva lista de roles: nombra una identidad, y los roles se resuelven del lado del servidor en cada petición. Una revocación invalida la resolución en caché de inmediato en vez de esperar a que venza su límite declarado de obsolescencia
delegationcambia el alcance, nunca la capacidad: actuar en nombre de un jugador cambia qué filas son visibles y a quién se atribuye una escritura, y no otorga ninguna operación que el Actor no tuviera ya
the verbresponde qué clase de efecto, y el predicado responde qué filas. Si dos casos difieren solo en de quién es la fila, eso es un predicado; si difiere el efecto mismo, eso es otra operación y quizá otro verbo — por eso «expulsar» es administer y no write con un predicado ancho
a hidden rowresponde not found: un rechazo no debe convertirse en un oráculo de existencia
an ownersiempre se ve a sí mismo, diga lo que diga cualquier otro predicado
visibilityno es seguridad — una optimización de canal y un permiso son mecanismos distintos, y ninguno sustituye al otro
a disabled moduleno tiene superficie: que un módulo esté habilitado es una propiedad del build, así que la generación de código no emite nada para uno deshabilitado y una llamada no disponible es un error de compilación y no un rechazo en runtime
a module's surfacesigue al Actor y no al lado (Authority lo argumenta, y esto es su mecanismo): un build de room-visitor ve entrar, salir y leer; un build de room-owner ve además expulsar, cerrar y configurar, y whoami reporta qué interfaces desbloquea el Actor actual
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

Quién reparte un rol. Otorgar y revocar un rol a un jugador, y el rol por defecto que el Project declara para uno nuevo, son operaciones de Auth & Players — ese módulo es dueño de las identidades, y un rol se resuelve por la identidad de la credencial. Esta página es dueña de lo que un rol es; esa página es dueña de entregarlo.

Errores

  • Lo que un predicado esconde responde not found, no forbidden — de lo contrario el rechazo mismo le dice a quien llama que la cosa existe, que es exactamente para lo que se la escondía.
  • Un derecho que quien llama no ostenta responde forbidden donde la existencia del sujeto no es un secreto, y nombra qué faltaba en vez de fallar en blanco.
  • Un campo fuera de la máscara está ausente de la respuesta, no presente y vacío: un valor vacío y uno enmascarado serían indistinguibles.
  • Un rol que se incluye a sí mismo, directamente o a través de una cadena, es un error de configuración — rechazado como Declaration en vez de resuelto en runtime.
  • La delegación nunca ensancha la capacidad: una llamada que el Actor no podría hacer en su propio nombre es rechazada cuando se hace en nombre de un jugador.

Límites

Cada techo nombra su comportamiento en la frontera; los números llegan con el capítulo de límites de la plataforma.

  • El tamaño de una selección bajo un predicado de fila está acotado, y el modelo de ACL declara esa cota en vez de descubrirla. Una lectura por encima del techo se responde con las filas que quepan en él más la marca que dice que fue cortada, nunca con una página corta en silencio.
  • El límite de obsolescencia de un permiso resuelto está declarado, y una revocación no espera a que venza — invalida de inmediato.

Flujo del usuario

Una clave de organizador de torneo, de la composición de roles a un cambio de permiso en vivo.

How the SDK works

Cómo está construido el SDK

Dos preguntas se confunden entre sí, y las dos tienen respuestas cortas. Cómo está escrito el SDK — por qué una misma idea se ve ligeramente distinta en Python y en C++ de Unreal. Cómo se ejecuta el SDK — qué hay entre tu llamada y el cable. Esta página responde ambas de una vez, para que no tenga que hacerlo ninguna página de módulo.

Escrito de lo general a lo particular

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

El SDK es un solo diseño con dos salidas de estrechamiento, y el orden es la regla: nada baja un nivel hasta que el nivel de arriba genuinamente no puede cargarlo.

NivelQué vive aquí
Los principios comunesIdénticos en todos los bindings: el comportamiento se declara como un atributo al lado de aquello que describe; toda Declaration que envías es una Declaration que el panel administrativo dibuja; tu código se dirige a módulos y a nada más.
La forma del lenguajeSolo lo que el paradigma de un lenguaje no puede expresar de la manera común. C# tiene atributos y Python tiene decoradores — la misma Declaration, escrita como cada lenguaje ya escribe esa idea. Un lenguaje sin tal construcción lleva la misma Declaration por otra vía, y ese portador se nombra donde aplica en lugar de darse por supuesto.
La forma del motorSolo lo que un motor de juego remodela encima de su lenguaje. El C++ de Unreal no es C++ llano — tiene su propio modelo de objetos y su propia reflexión en tiempo de build, así que ahí una Declaration viaja dentro del macro de reflexión del propio motor, en la posición donde ese macro ya toma especificadores. El C# de Unity tampoco es el C# del servidor: un runtime más viejo, una biblioteca base más chica.

Leído de arriba abajo, esto es por qué las seis pestañas de cada ejemplo no son seis APIs distintas. Son una sola API, escrita de seis maneras, y las diferencias que ves son los dos niveles inferiores asomando.

Cómo se ejecuta, desde tu código hacia abajo

El código de tu juego ve módulos. Eso no es una simplificación para la documentación — es todo el contrato del nivel superior.

  • Los módulos son a lo que te diriges. Forman un grafo, no un árbol, y lo que eso te da es Inheritance & Composition.
  • Los cuatro Primitives son aquello con lo que se ensamblan los módulos — Events, RPC, datos y suscripciones, Groups. Una Room, un chat y una pila de matchmaking son el mismo Primitive de Group bajo reglas distintas. Si una funcionalidad no puede expresarse con los cuatro, eso es un defecto de diseño, no un argumento a favor de un quinto.
  • El hub está debajo, y nunca lo llamas: inyección de dependencias, montaje de módulos, la sesión de usuario, la recuperación de estado y la calidad de servicio de los mensajes. Se lo nombra una vez en Under the Hood.
  • Los adaptadores de transporte están al fondo, uno por protocolo, y el hub los oculta por completo. Habrá varios — WebSocket, nuestro propio UDP, HTTP — y cuál de ellos lleva una llamada no es algo que tu código decida ni note.

Lo único que un módulo te dice sobre la entrega es su calidad de servicio — al menos una vez, o a lo sumo una vez. Todo lo demás sobre cómo llegaron los bytes hasta allí deliberadamente no es tuyo para saberlo, porque es la parte sobre la que nos reservamos el derecho de hacerla más rápida.

Qué sacas de esto

  • Un solo SDK, no uno de cliente y otro de servidor. Lo que una llamada puede hacer es la concesión del Actor, no una bandera de build. Eso es Authority, y es la decisión de mayor consecuencia de esta página.
  • Una Declaration es la entrada de todo. Envíala y aparece la API tipada, el panel administrativo la dibuja, y la generación de código de cada binding la sigue. Consulta Schema as Code.
  • Los módulos se componen en vez de heredar. Cómo, y qué significa honestamente aquí «herencia», es Inheritance & Composition.
How the SDK works

Hilos, tiempo de vida y pruebas

El bucle es tuyo. Nosotros entregamos en exactamente un lugar, y nunca a tus espaldas. El SDK no arranca ningún hilo del que tengas que enterarte, no te entrega ningún candado, y llama a tu código desde un único contexto que elegiste al iniciar. Llámanos desde el hilo que quieras; nosotros te llamamos desde uno.

Un solo contexto de entrega, y el bucle es tuyo

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

Una instancia declara exactamente un contexto de entrega — el único lugar donde corren todos sus manejadores. Queda fijado cuando inicializas y no cambia durante la vida de la instancia. Un Event, un Delta de datos, el desenlace de una llamada: todos llegan ahí y a ningún otro sitio.

Tiene dos formas, y eliges una al iniciar:

  • Tú lo bombeas. El runtime no hace nada por su cuenta; tú drenas las entregas pendientes desde tu propio bucle. Esta es la forma que quiere un motor — las entregas aterrizan en el hilo del juego, en un frame que elegiste.
  • Nosotros lo poseemos. El runtime mantiene un hilo de ejecución dedicado. Esta es la forma que quiere un host de consola o un servidor dedicado.

Ninguna es respaldo de la otra, y no hay una tercera opción que involucre una pila de hilos. La gracia de prometer un solo contexto es que nunca tengas que preguntar cuántos hilos hicimos.

El contexto nunca es un argumento. Ningún manejador toma un parámetro de «en qué hilo estoy», y no hay nada que consultar. Dónde corre tu manejador es una propiedad del contrato, no un dato de la llamada.

Arrancar y parar son explícitos

La inicialización es una llamada que haces tú, y responde con un desenlace. Nada se inicializa de forma perezosa en el primer uso — eso está prohibido en vez de meramente desaconsejado, y la razón merece una frase: un arranque perezoso mueve el único lugar donde un módulo deshabilitado es visible hacia la llamada arbitraria que resultó ser la primera, donde se lee como si fallara esa llamada.

Un módulo deshabilitado se nombra al iniciar, y el desenlace dice cuál de dos cosas pasó: la cadena dependiente entera está apagada, o estás corriendo con menos, más la lista de lo que no está disponible. No hay un tercer caso silencioso.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Apagar no cancela nada. Este es el único punto donde un hábito de la mayoría de los SDK es activamente erróneo aquí. El apagado es explícito, completo e idempotente — después de que tiene éxito ningún manejador de esa instancia vuelve a ser llamado — pero no dice nada sobre el trabajo que ya iba en camino. Una operación que empezaste antes de apagar sigue siendo descubrible por los medios que esa operación nombró. Si necesitas saber si una compra pasó, apagar no es la forma de averiguarlo.

Todo handle tiene un final declarado — y nunca es el recolector de basura

Una suscripción, un descriptor de trabajo diferido, una sesión: cada uno es un handle, y cada uno tiene exactamente un final que el contrato nombra. Liberar es idempotente, así que liberar dos veces no es un error.

Tres consecuencias fáciles de equivocar:

  • El final nunca es un finalizador, un destructor ni un ámbito. Un handle que sueltas al piso queda abierto. Eso es un bug de tu código, no algo que recuperemos calladamente — porque un tiempo de vida que dependiera del lenguaje sería un tiempo de vida distinto en cada binding.
  • Usar un handle después de su final es un rechazo declarado, con un código. No un resultado vacío, no comportamiento indefinido, y no un error genérico de objeto desechado que no lleva nada sobre lo que puedas actuar.
  • Una conexión caída no es el final de un handle. Una suscripción sobrevive a una desconexión y sigue recibiendo tras la reconexión. Los handles terminan por las razones que el contrato nombra, y perder la red no es una de ellas.

Ningún handle sobrevive a la instancia que lo emitió: una vez que apagas, todo handle que te dio está en su final.

Llamar desde un manejador está bien; esperar dentro de uno no

Llama a la superficie desde cualquiera de tus hilos. Todo handle es libre de hilos, y eso es una promesa y no una propiedad del build de hoy. Nunca tomarás nuestro candado, ni esperarás en nuestra barrera, ni te dirán que llames a algo «bajo un candado» — ninguna primitiva de sincronización forma parte de la superficie.

Los manejadores de una instancia están serializados: nunca corren dos a la vez, y el orden dentro de un Stream se preserva. Así que un manejador no necesita candados propios.

Serializado no quiere decir deduplicado. El orden es una promesa; cuántas veces se entrega un mensaje es otra distinta, declarada en el tipo de mensaje. Bajo entrega al menos una vez verás el mismo mensaje dos veces, y la clave de deduplicación que siempre viaja con él es cómo lo distingues.

Iniciar una operación desde dentro de un manejador es legal y no puede llegar a un interbloqueo. Su desenlace, eso sí, nunca llega dentro de ese mismo manejador — vuelve como una entrega aparte sobre el mismo contexto. Ir hacia adentro está permitido; dar la vuelta ahí dentro no.

Bloquear el contexto de entrega está prohibido, y la prohibición no es un consejo. Esperar a la red, esperar el candado de otro, esperar de forma síncrona a tu propia llamada: todo prohibido dentro de un manejador. La prohibición tiene un síntoma — un manejador que retiene el contexto más allá de su presupuesto declarado produce o bien una degradación declarada de la entrega o bien un rechazo declarado. Lo que nunca produce es una lentitud silenciosa que te toque descubrir en la sesión de un jugador.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

«Cancelar» son dos cosas distintas

Una palabra en la mayoría de los lenguajes, dos operaciones aquí, y la diferencia es observable:

Lo que quieresQué es
deja de entregarmelocal. Siempre tiene éxito, incluso con la conexión caída. Liberar una suscripción es esto.
detén el trabajouna petición a la plataforma. Idempotente, y no promete nada sobre si el trabajo ocurrió.

La segunda es la que la gente confunde. Cancelar trabajo ya aceptado es una petición que puede no llegar a tiempo — exactamente como un timeout, que tampoco quiere decir «no se aplicó». Tras cualquiera de los dos tipos de cancelación, el desenlace de una operación no idempotente ya iniciada sigue siendo descubrible por los medios que esa operación nombró.

Y los dos fallos son distinguibles: cancelar una llamada que nunca nos llegó es un fallo local; cancelar trabajo que habíamos aceptado te da un estado terminal de un conjunto declarado.

Lo que recibes es una copia del pasado

Un valor entregado a tu manejador no cambia después. Nunca repartimos una referencia viva a nuestro propio estado, así que nada de lo que tienes muta entre dos líneas de tu código.

Guardar un valor entregado más allá del manejador es por tanto seguro — pero lo que guardaste es la observación de un momento, no una ventana al presente. Los Deltas pueden fusionarse camino a ti, así que una lista de valores que guardaste no es un historial de lo que pasó.

Tampoco eres nunca dueño de nuestros búferes. No hay pedir-y-devolver, ni ensamblar-y-enviar: cambiar estado declarado es la operación de red.

Pruebas: una implementación en memoria, no un mock

Hay una implementación completa en memoria — la misma superficie, el mismo conjunto de desenlaces declarados, sin red. Es una cosa aparte de la que dependes, no una bandera sobre el runtime de producción.

  • No es parcial. Una operación que no soporta se rechaza con un código declarado, nunca se responde con un éxito inventado. Una prueba que pasa contra ella pasa por una razón.
  • El tiempo es tuyo. Los plazos declarados — el tiempo de vida de un descriptor de trabajo, una reserva, una ventana de retención — se alcanzan avanzando un paso, no durmiendo.
  • El determinismo es declarado y acotado: el orden dentro de un Stream, el modo de entrega declarado, el tiempo controlable. El determinismo en coma flotante no está prometido, así que una simulación completa tampoco se reproduce aquí.

La diferencia con un mock es justamente el punto. Un mock comprueba que llamaste a lo que querías llamar. Esto comprueba que lo que llamaste tiene sentido.

Qué instalas, y el piso de versión

El núcleo es una unidad; los módulos opcionales son unidades aparte, cada una con una composición declarada y una lista declarada de dependencias obligatorias. Agregar una unidad nunca cambia la superficie de otra — un módulo se monta donde dice su Declaration, así que nada aparece ni desaparece en otro lado por lo que instalaste al lado.

Si se referencia una unidad opcional pero no puede cargarse, eso es un desenlace declarado de la inicialización — el mismo lugar donde se reporta un módulo deshabilitado. Nunca un stub que en silencio no hace nada.

Todo binding declara la versión mínima de runtime contra la que está construido. Por debajo de ella recibes un rechazo en la inicialización, no operación parcial: un runtime demasiado viejo se rompe si no en la primera capacidad que le falta, que está en algún punto arbitrario de tu código y por lo general en la máquina de un jugador y no en la tuya. Subir ese mínimo es un cambio rompedor y pasa por el mismo proceso que cualquier otro.

Adónde ir después

Building blocks

Core

Core es el único objeto que creas, y todo lo demás cuelga de él. Una clave de entrada, y tienes contexto, identidad, fallos tipados, trazado y agrupamiento en lotes. Toda llamada de módulo pasa por él, y ningún módulo trae su propia versión.

Who addresses Core, the 4 things it provides, and the 1 module it builds on

Cuándo usarlo

  • Necesitas saber quién y dónde eres — identidad, roles, módulos desbloqueados, Project · env · región, todo en el único objeto que sostienes.
  • Una función en la nube debe escribir como un jugador — la escritura se le atribuye a ese jugador, y el registro nombra a las dos partes: la función y el jugador.
  • Los reintentos nunca deben aplicarse dos veces — las operaciones en lote llevan una clave de idempotencia.
  • Un fallo debe poder ramificarse y buscarse — cada lanzamiento es un Problem tipado con un código estable.
  • Sáltatelo cuando lo que buscas es mensajería, llamadas o estado — esos son los Primitives: Events, RPC, Data.

Quién hace qué

ActorEn esta página
any actorlee identidad, contexto y roles mediante Whoami
backend-serviceactúa como un jugador; agrupa en lotes operaciones idempotentes
operatorlee trazas de llamadas fallidas o reintentadas

De un vistazo

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

La identidad vive dentro del propio Core. Ningún parámetro session o ctx aparece jamás en una llamada.

El modelo

Qué lleva todo error.

CampoQué es
codeel nombre del rechazo, legible por máquina, y es estable. El vocabulario es una proyección de los códigos que la plataforma ya tiene: un código nuevo para un rechazo que la plataforma ya nombra está prohibido
categoryla clase a la que pertenece el rechazo, que es la que dice si un reintento tiene sentido siquiera
trace identifierel identificador de esta ocurrencia en concreto, presente siempre, errores locales incluidos, de modo que contactar a soporte nunca exige reproducir la falla primero
explanationtexto humano para que lo lea una persona, y no es estable: los títulos y las explicaciones cambian y se localizan en cualquier momento
per-field errorsla lista que lleva un rechazo de validación: campo, código y mensaje por cada campo rechazado

Un consumidor se ramifica por el código y la categoría, nunca por el texto humano — ni por comparación, ni por subcadena, ni analizándolo. Un error del que solo se puede alcanzar el mensaje es un defecto del binding y no una forma que haya que sortear.

Tres orígenes, y no son lo mismo.

OrigenQué pasó
platformrespondió con un rechazo, llevando un código del catálogo de la plataforma
localel SDK rechazó antes de enviar, desde su propio vocabulario publicado
unknownla llamada se envió y no volvió respuesta. Ni «la plataforma dijo que no» ni «nunca preguntamos»

Qué vale para todo rechazo.

SiempreQué es
a refused operation applied nothingla atomicidad es obligación de la plataforma, no tuya: nada de lecturas compensatorias en una rama de error corriente. Se exceptúan exactamente dos casos y ambos lo dicen donde surgen — un timeout, cuyo desenlace es desconocido, y un lote bajo semántica por elemento
the delivery pathno cambia el error: te llegan el mismo código, la misma categoría y el mismo origen tanto si el binding lanza, como si devuelve un valor de resultado, como si llama de vuelta por una suscripción. Un camino que lleve menos que otro es un defecto de ese binding
a timeoutno es un desenlace: es el tercer origen de arriba, y qué hacer al respecto se declara por operación en vez de adivinarse

Core lleva el contexto, no los mensajes. Emitir hechos y suscribirse a ellos es el Primitive Events; las llamadas — petición/respuesta, de una vía, reparto a un Group — son el Primitive RPC; el estado, las suscripciones y las lecturas por Stream son el Primitive Data, direccionado a través de Entity. Las audiencias a las que los tres reparten son el cuarto Primitive, Groups. Las transferencias con tamaño (subidas, descargas) afloran en Files & UGC. La semántica de montaje — espacios de nombres, rechazo de colisiones en tiempo de montaje — vive en Under the Hood.

Errores

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

Qué ve quien llama sin el rol. Las filas fn y adm se rechazan, no se degradan: una sesión de jugador o de cliente que llama a act as player, emite una traza o lee una recibe un Problem con código forbidden — la credencial es válida, los roles que hay detrás no llevan tal derecho, y repetir la llamada con la misma clave de idempotencia no cambia nada. No hay una variante reducida que corra con menos derechos y devuelva menos.

Todo fallo es un Problem tipado con un código estable: los mismos códigos que documenta el contrato del cable, de modo que un cliente pueda ramificar por ellos y un humano pueda buscarlos.

Límites

Un límite se aplica en la admisión. Una llamada que fue aceptada ya pasó el límite y se llevará a cabo, por mucho que espere a ser procesada; un rechazo que cae sobre alguna llamada posterior no hace nada al trabajo ya admitido. Así que una cola que se llenó es una cola que corre — reintentar la llamada aceptada porque a una vecina la rechazaron es la forma de hacer el trabajo dos veces.

Un límite lo aprendes siendo rechazado, y no hay nada más que leer. El SDK no expone ni el valor en vigor, ni el margen que queda, ni un aviso de que uno se está acercando, y nada sobre un límite se le pone jamás delante a un jugador. El rechazo lleva todo lo que hay:

  • la categoría, que es lo que dice si un reintento tiene sentido siquiera
  • de quién era el límite
  • cuándo se permite un reintento, y sobre qué ventana

Ramifica por eso. No hay contador que consultar ni presupuesto que mostrar.

Flujo del usuario

Una llamada que falla, del lanzamiento a la traza que lee un operador.

Building blocks

Events

Un Event es el hecho de que algo pasó, entregado a todos los que deben enterarse. Úsalo para lo que pasa una vez y no puede recuperarse a partir de un valor actual — un disparo, una compra, una entrada a una Room.

Who addresses Events, the 5 things it provides, and the 1 module it builds on

Cuándo usarlo

  • Algo pasó y otros deben reaccionar — un disparo hecho, una puerta cerrada, una partida terminada.
  • La audiencia varía — la misma emisión llega a un escuadrón, a una Room o a un solo Actor, según el destino que declare el tipo.
  • Quieres manejadores tipados con autocompletado — un Event declarado se convierte en send. y on. sobre su superficie, cada uno con su propio contrato.
  • El hecho debe seguir siendo legible una hora después — declara el tipo como retenido y léelo de vuelta por periodo.

Quién hace qué

ActorEn esta página
schema-authordeclara Events con [Event], envía el schema
any actoremite mediante send., se suscribe mediante on.

De un vistazo

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

Un Event declarado dentro de un módulo o de un Group aflora solo ahí: squad.send.rallyCall existe porque rally_call está declarado para escuadrones, y la emisión llega a los miembros del escuadrón. Un Event que un módulo emite hacia afuera forma parte de su contrato declarado; quienes llaman nunca se enteran de los no declarados.

El modelo

Qué declara un tipo de Event.

DeclaraQué es
nameun nombre de cable explícito, declarado en vez de derivado del símbolo
payloadel schema de lo que lleva una emisión
targetadónde van las emisiones de este tipo: una instancia de Entity, un Group, una Room o el contexto global. Un destino de Group es entrega masiva — una señal, muchos destinatarios. Un destinatario cambiante se expresa como Group, nunca como una dirección pasada en la emisión
clocksim_time o timestamp, nunca ambos — sim_time para hechos dentro de una simulación, que participan de la predicción, la compensación de lag y el rebobinado; timestamp para hechos fuera de ella, como una compra o un sign-in
retentiontransient — llega a quien esté suscrito al momento de la emisión y no se almacena; o retained — se almacena y se lee de vuelta por tipo y periodo, no por la superficie de consulta que lleva Data. Declarado, nunca inferido de la clase de Event
termen un tipo retained: cuánto se conserva, y qué pasa al vencer. «Para siempre» no es uno de los valores
deliverya lo sumo una vez, al menos una vez o exactamente una vez — declarado en el tipo, de modo que un suscriptor nunca tenga que preguntar cuál usó una emisión; «exactamente una vez» enuncia los límites dentro de los cuales se cumple
contextel contexto en el que se declara el tipo, global o local. Un nombre declarado globalmente es visible en contextos locales; uno declarado localmente no es visible más arriba. Lo que un módulo emite es su contrato en cualquiera de los dos casos — quien llama nunca se entera de un Event no declarado

Qué lleva una emisión.

CampoQué es
typeel Event declarado. Dos emisiones nunca se fusionan: dos disparos son dos Events, y el segundo no absorbe al primero — que es lo que separa un Event del campo [Sync] que lleva Data
payloadconforme al schema del tipo
sourceel Actor emisor, más su instancia cuando lo emitió una Entity. Un Event emitido por un cliente es una afirmación, no un hecho: el lado autoritativo lo comprueba antes de que nada dependa de él
stampen el reloj declarado del tipo
dedup keypresente bajo todos los modos de entrega, porque la reentrega es posible en todos ellos — un duplicado de transporte, una segunda lectura de un Event retenido
cause keyen un Event que la plataforma emite a causa de otro Event de plataforma: el id de aquello de lo que se sigue, de modo que una cadena se reconstruye por clave y jamás comparando sellos

Qué lleva una suscripción.

SostieneQué es
eventel tipo declarado al que está ligada
surfaceel nodo sobre el que se toma, dentro del target declarado del tipo — la mitad de la audiencia que le toca al suscriptor
handlertipado al payload
positiondesde dónde retoma, declarado, de modo que una reconexión no reinicie en silencio en «ahora». Lo que se perdió en el hueco no se reproduce: un Event transitorio es irrecuperable, y solo uno retained puede leerse de vuelta

Qué vale para todo Event, declare lo que declare el tipo.

SiempreQué es
audiencenunca la enumera quien envía: es el target declarado del tipo estrechado a quien esté suscrito, y luego filtrado por Access — publicar y suscribirse son derechos separados y ninguno implica al otro, y un flujo puede cerrarse por un predicado incluso donde el tipo en sí es visible. Quien enviara y pudiera listar destinatarios tendría que reproducir lo que Groups y Data ya saben
phasesemitido, luego entregado — y nada más. Un Event no tiene máquina de estados: ocurre una vez
orderingprometido dentro de un Stream, y para Events un Stream es una instancia emisora: dos Events de la misma instancia llegan en orden de emisión. Entre Streams no se promete orden de ninguna forma — ni entre dos instancias, ni entre un Delta y un Event sobre el mismo cambio
crossing streamscuando hace falta orden entre Streams el mecanismo se declara, nunca se supone: junta los mensajes en un solo Stream, o lleva un sello causal en el payload
gap detectiondonde el modo admite pérdida, el suscriptor se entera del hueco en vez de saltárselo en silencio

Si un hecho se conserva después de la entrega es una ranura de su Declaration, no una decisión tomada en la emisión — así que el mismo tipo se conserva siempre igual y ningún llamador tiene que recordar cuál llamada era cuál.

TransitorioRetenido
Llega aquien esté suscrito en ese momentoeso, y a un suscriptor que llegue después
Despuésse fuese conserva por un plazo declarado
Legible de vueltanosí, a lo largo del plazo
Pasado el plazo—una selección rechaza, en vez de responder vacío
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

Errores

  • Publicar y suscribirse son derechos separados, y ninguno implica al otro. Una suscripción sin el derecho responde forbidden, no not-found — el tipo está en el contrato declarado del módulo, así que no hay nada que esconder.
  • Suscribirse a un tipo que el módulo no ha declarado es un error de contrato, expuesto como un Problem tipado — nunca una operación nula en silencio.
  • En la emisión, tres rechazos de validación: un tipo no declarado, un payload que no cumple el schema, y un destino que el tipo no permite.
  • Una selección más allá del plazo de un tipo retenido rechaza, en vez de responder con una página vacía.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Tamaño del payload — por encima del techo la publicación falla y el Event no ocurre, nunca un payload truncado.
  • Tasa de publicación por origen — un rechazo por límite de tasa que lleva el momento de reintentar.
  • Suscripciones por Actor — se rechaza la nueva y se conservan las existentes.
  • Volumen de retención por tipo — desalojo por la política declarada, por plazo, nunca al azar.

Flujo del usuario

Una llamada de reagrupamiento, de la Declaration a la marca que cada miembro del escuadrón ve en su propia pantalla.

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

RPC

Una llamada tipada cuyo cuerpo vive en otra parte. El RPC es el segundo Primitive: declara el procedimiento donde corresponde — en un módulo, o dentro de una Entity — y cada binding recibe un método generado y esperable. El verbo es invoke: la vía única es un modo que nombra la Declaration, no un segundo verbo, y no hay ningún do.

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

Cuándo usarlo

  • Quien llama necesita una respuesta — petición/respuesta con un retorno tipado.
  • Quien llama informa y sigue adelante — un RPC de vía única declarado, nada viaja de vuelta.
  • El trabajo dura más que la llamada — un RPC diferido declarado devuelve un descriptor de trabajo en vez de un timeout.
  • Una pregunta, muchos que responden — una llamada a un Group son N llamadas, y cada respuesta llega ligada al miembro que la envió.
  • El verbo pertenece a una cosa — decláralo dentro de la Entity; el RPC de una Entity no vive en ninguna otra parte (Entity muestra la Declaration).
  • Sáltatelo cuando no se le pide a nadie que actúe — un hecho al que otros simplemente reaccionan es un Event.

Quién hace qué

ActorEn esta página
schema-authordeclara los RPC, sus modos y quién puede llamarlos
any actorinvoca una llamada con respuesta o de vía única, donde la Declaration lo permite
group memberresponde a una llamada repartida; vuelve una respuesta por miembro

De un vistazo

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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);

Dónde se ejecuta el cuerpo — función en la nube, cliente, master-client o servidor de juego — es enrutamiento, declarado por método; Extensibility cubre las sobrescrituras y el middleware. Una llamada fallida lanza un Problem tipado (Core).

El modelo

Qué declara un RPC.

DeclaraQué es
namedel vocabulario de verbos
inputlos argumentos que quien llama debe elegir
outputexactamente un tipo declarado. Una respuesta más corta es un tipo declarado propio, nunca el mismo tipo con campos omitidos calladamente — de lo contrario «no se pidió», «el objeto no está» y «oculto por la máscara de acceso» se vuelven una única ausencia indistinguible
reply modewith a reply — un valor del tipo de salida declarado o un rechazo tipado; o one-way — sin respuesta, y quien llama solo se entera de un fallo local de envío. La vía única no debe usarse donde quien llama necesita el desenlace: un desenlace desconocido cuesta más que un rechazo conocido
execution modeimmediate — el desenlace vuelve dentro de la llamada; o deferred — la llamada devuelve un descriptor de trabajo y el desenlace se lee o llega por suscripción. Declarado, nunca elegido por la implementación según la carga, porque quien llama construye su comportamiento sobre la forma de la respuesta
streamingsi la entrada y la salida llegan por partes y se manejan a medida que llegan, en vez de como un todo
idempotencyun RPC de vía única también lleva una clave de idempotencia: que no haya respuesta no quiere decir que no haya reentrega
overridabilitydeclarada en el propio método. Sin Declaration significa no sobrescribible — nunca sobrescribible por defecto
contextdónde se declara. Un RPC declarado dentro de una Entity es parte de esa Entity y no existe fuera de ella. Declarar uno en el servidor de juego es registrarlo en el enrutador — no hay una segunda manera de agregar uno

Qué lleva una invocación.

LlevaQué es
argumentssolo lo que quien llama debe elegir
implicit contextel receptor, quien llama y el contexto ambiente, ligados antes de tu primer parámetro escrito — a un método de una Entity nunca se le pide el identificador de esa Entity
referencesun argumento que es un objeto del SDK viaja como una Ref tipada — un identificador o un cursor, nunca una copia de su contenido. El destinatario lo resuelve en su propio nombre, bajo los mismos permisos y predicados: una referencia es una dirección, no un permiso otorgado
outcomeun valor del tipo de salida declarado, o un Problem tipado

Qué lleva el descriptor de una llamada diferida.

SostieneQué es
stateaccepted → running → completed o failed, los dos últimos terminales
lifetimedeclarado; pasado él el desenlace no está disponible y pedirlo es un rechazo, no una respuesta vacía
cancelidempotente, y honesto: pide, y el estado terminal que observas es aquel de completed o failed al que llegó el trabajo

Qué vale para todo RPC.

SiempreQué es
one handlerexactamente un manejador lógico — que es lo que separa un RPC de un Event, donde puede no haber ninguno. Así que dirigirse a un Group son N llamadas y no una: Groups provee las direcciones, y las respuestas vuelven como un Stream, cada una ligada al miembro que la envió
meaninguna petición de realizar una acción, mientras que un Event es la afirmación de un hecho. Un RPC de vía única y un Event se parecen desde afuera y no son lo mismo: el manejador de un RPC está obligado a existir, un Event puede no tener destinatarios en absoluto y eso es normal
no state machineuna Declaration no tiene ninguna, y una llamada inmediata tampoco — o devolvió un desenlace o no lo hizo, y entonces rigen las reglas de timeout. Solo una llamada diferida tiene estados observables
a streamno es atómico: una salida por Stream no promete nada sobre el todo: un receptor tiene que estar listo para una interrupción y para distinguir «el Stream se completó» de «el Stream fue interrumpido»
no predicate on a writeninguna escritura acepta un predicado como entrada: «haz esto para todos los que cumplan esta condición» no es una operación. Una acción masiva se expresa por enumeración — lee el conjunto, entrega la lista a una operación por lotes con semántica de fallo parcial declarada. Como entrada de una escritura, un predicado se evalúa en un momento que nadie nombró, sobre un conjunto que nadie vio

Todo RPC llega a su manejador por el enrutador, y cuál de sus direcciones responde se declara por método en vez de ser una propiedad del sitio de la llamada — consulta Extensibility.

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

Errores

  • El derecho está en el RPC, nunca en el Primitive. No hay un «puede invocar» general: cada Declaration nombra el átomo que quien la llama debe ostentar, y un llamador sin él recibe un rechazo tipado de prohibición, con código y todo — no una caída silenciosa.
  • Una instancia oculta se lee como «not found». Un RPC de Entity invocado sobre una instancia que el predicado de fila de quien llama esconde responde exactamente igual que leer esa instancia, así que el rechazo no le dice nada a quien llama sobre lo que existe.
  • Sin manejador es «no disponible», no «not found». El RPC está declarado, así que existe; lo que falta es una ruta. Ese rechazo es repetible — un servidor de juego puede volver — mientras que «not found» le diría a quien llama que deje de intentar.
  • Un rechazo de Hook lleva el código y la razón del propio Hook, así que «rechazado por una regla del juego» nunca llega pareciendo «se rompió el transporte».
  • Un timeout no es un desenlace. Para una llamada diferida lees el descriptor; para una inmediata la clave de idempotencia declarada es lo que hace seguro el reintento — incluso en una llamada de vía única, donde la ausencia de respuesta no es ausencia de reentrega.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Tamaño de la entrada — la llamada se rechaza antes de ejecutarse.
  • Tamaño de la salida — rechazada en vez de truncada, porque una respuesta recortada es indistinguible de una completa.
  • Tasa de llamadas por Actor — un rechazo por límite de tasa que nombra cuándo reintentar.
  • Llamadas diferidas concurrentes por Actor — se rechaza la nueva y las que van en camino terminan.
  • Tiempo de vida del descriptor — pasado él el desenlace no está disponible, y eso es un rechazo.
  • Profundidad de la cadena de llamadas — un rechazo declarado al excederla, nunca recursos agotados ni una ruptura silenciosa.

Flujo del usuario

Un puntaje enviado, un ping informado, un escuadrón al que se le pregunta si está listo.

Building blocks

Data

Cambias un campo. Todo lo que sigue río abajo ocurre sin una sola línea de código. Data es el tercer Primitive: la mecánica que hay bajo todo campo sincronizado — Deltas contra el último estado confirmado, el aspecto como unidad de política, prioridad y tasa de envío, suscripciones reanudables, la ventana retenida, y Hooks antes y después del cambio.

Te diriges a Entities, no a tablas — consulta Entity para la superficie de lectura y cambio (buscar, filtrar, ordenar, paginar, suscribirse a una selección); esta página es la mecánica de debajo. No hay camino de consumidor hacia una tabla, y no hay una segunda manera de escribir: un cambio es una operación de Entity, y el Delta es lo que se sigue de ella.

Who addresses Data, the 5 things it provides, and the 1 module it builds on

Cuándo usarlo

  • Necesitas estado replicado a los clientes sin código de snapshots — cambiar un campo es toda la sincronización.
  • Los campos difieren en urgencia o en audiencia — prioridad y un techo de tasa de envío por aspecto, y un predicado de visibilidad para la niebla de guerra.
  • Un cliente que se reconecta no debe divergir en silencio — un hueco se detecta y se nombra, y un hueco más allá de la ventana retenida se responde con el estado completo.
  • Necesitas el pasado reciente — la ventana retenida de Deltas, indexada por sim_time, es lo que leen la predicción y la compensación de lag.
  • Una regla de validación pertenece a un solo lugar — un Hook previo al cambio recorta o veta antes de que el cambio aterrice.
  • Sáltate las perillas cuando todo lo que necesitas es leer o consultar — la superficie de Entity va montada sobre esta mecánica sin tocarla.

Quién hace qué

ActorEn esta página
schema-authordeclara los aspectos, su política de sincronización y el predicado de visibilidad
any actorse suscribe a un destino; retoma desde una posición; pide el estado completo
backend-serviceHooks antes y después del cambio
operatorlee el costo de paquete por Actor; ve cuándo se degrada la entrega o se corta un paquete

De un vistazo

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

El modelo

Qué lleva un Delta.

CampoQué es
changed fieldssolo esos, nunca el objeto entero
pairel par instancia × aspecto al que pertenece
numberun número de secuencia dentro de ese par, que es lo que hace detectable un hueco

Qué declara un aspecto.

Todo ello por atributo, sobre el aspecto o sobre un campo suelto, nunca por una llamada en runtime. El aspecto fija el valor por defecto y un campo puede sobrescribirlo; el aspecto sigue siendo la unidad de política, porque de lo contrario no habría con qué ensamblar presets.

DeclaraValores, y qué no es
priorityordena qué se envía primero cuando el canal no alcanza. No es una promesa de latencia: es relativa, y ordena el envío entre campos en vez de garantizar un plazo de entrega
max update rateuna cota superior sobre el envío. No es una promesa de recibir a esa tasa — recibir depende del canal
delta onlyno enviar lo que no ha cambiado
delivery modeshared packet — lo mismo para todos, barato en CPU; o per-actor packet — cada uno el suyo según su zona de visibilidad, caro en CPU y necesario con poblaciones grandes
visibility ruleel predicado que decide quién recibe siquiera — Visibility proyecta esa mitad por completo
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

Qué lleva una suscripción.

SostieneQué es
targetuna instancia, una selección o un aspecto, y recibe los Deltas de ese destino. Un destino no es un Stream: un destino puede abarcar muchos pares, y el orden se promete dentro de un par y no a lo largo de un destino
positiondesde dónde retoma: la presenta el consumidor. Si el hueco es más grande que la ventana retenida llega el estado completo en vez de un Stream de Deltas, así que una desconexión larga nunca deja a un cliente equivocado en silencio
stateactive → gap detected → resynchronised | closed, y closed es terminal

Qué vale para todo Stream.

SiempreQué es
merginglos Deltas lo admiten: 100 → 90 → 80 entre envíos puede llegar como 100 → 80, porque el estado final sigue siendo correcto. Eso es exactamente lo que separa un Delta de un Event, donde perder uno pierde información para siempre
gap detectionperder un Delta en silencio está prohibido; el número de secuencia del par es lo que cuenta el consumidor
orderingse cumple dentro de un par instancia × aspecto; entre pares no se promete de ninguna forma
traversales solo sobre cosas declaradas: lo que puede ser un filtro, un orden o una inclusión es un campo declarado y una referencia declarada. La superficie de selección de Entity es la proyección de ese modelo, y este Primitive no le da al consumidor un recorrido propio — no hay un segundo lenguaje de consulta
historyse construye con Deltas: la ventana instantánea de una Entity es una ventana retenida de Deltas indexada por sim_time. Su profundidad es el límite de este Primitive, y no promete reproducibilidad sobre campos de coma flotante
the packet budgetse degrada como está declarado: cuando se agota el presupuesto por Actor la plataforma cae al paquete compartido tal como está declarado, en vez de empezar a perder destinatarios arbitrariamente

Qué puede hacer un Hook, y cuándo.

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

HookQué puede hacer
antes de un cambiomutarlo, o vetarlo. Un cambio vetado no produce ningún Delta — los suscriptores no ven nada, en vez de ver un valor y luego una corrección
después de un cambioagregar efectos secundarios, y nunca puede hacer fallar el cambio

El borrado se engancha en Entity, donde vive el borrado; este Primitive engancha el cambio.

Errores

  • Un destino de suscripción no declarado es un rechazo de validación.
  • Sin permiso para suscribirse responde forbidden o not found según si la existencia del destino es en sí misma un secreto — el rechazo no debe volverse un oráculo.
  • Una posición de reanudación que no se puede analizar es una petición mal formada, nunca un reinicio silencioso desde ahora.
  • Una suscripción cerrada por el lado de la plataforma es un conflicto, y es observable: el closed de la máquina es terminal y llegar ahí no es algo que un cliente tenga que inferir.
  • La cuenta de suscripciones agotada es un conflicto — el permiso se tiene, el cupo no.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • La ventana de retención de Deltas — retomar desde algo más viejo que la ventana da el estado completo en vez de un rechazo.
  • Suscripciones por Actor — se rechaza una nueva y las existentes continúan.
  • Tamaño del Delta — el Delta se parte en vez de truncarse, y la partición es observable.
  • La tasa de envío — una cota superior, no una garantía.
  • El costo de un paquete por Actor — al agotarse, degradación declarada al paquete compartido.

Flujo del usuario

Un cambio de posición, de la asignación al movimiento corregido en cada pantalla.

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Groups

Una lista, un oyente masivo. Un Group es el cuarto Primitive: un conjunto nombrado de actores que recibe como uno solo. Te diriges al Group y cada miembro oye — una Room, un chat, una pila de matchmaking y una lista de envío son el mismo Primitive con reglas distintas: distinta lógica de entrada y salida, distinto tiempo de vida, la misma lista debajo.

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

Cuándo usarlo

  • Necesitas parties, escuadrones o gremios — conjuntos nombrados de jugadores con una capacidad declarada y, donde el tipo declare uno, un tiempo de vida.
  • La membresía debe seguir una regla declarada que la plataforma evalúa — los nuevos veteranos entran solos, sin un cron y sin una llamada de reevaluación propia.
  • Quieres dirigirte a muchos jugadores a la vez: un Event declarado se reparte con send.*, un RPC declarado llega a cada miembro y cada respuesta vuelve con nombre.
  • Necesitas un solo modelo de membresía reutilizado como audiencia — un alcance de Visibility, una conversación de Messaging, una party de Matchmaking.
  • Sáltate crear uno cuando el conjunto son los miembros de una sesión — Rooms es este Primitive con reglas de Room, y ya se dirige a ellos.

Quién hace qué

ActorEn esta página
playercrea Groups a partir de tipos declarados, entra y sale, agrega o quita miembros, envía Events, invoca RPC repartidos; ostentando el derecho de administración sobre la membresía de un Group, quita miembros y lo cierra
room-ownerlas reglas de asientos de una Room van montadas sobre este Primitive (se configuran en Rooms)
backend-servicedeclara los tipos de Group y sus reglas; Hooks en la entrada y en la salida

De un vistazo

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

El chat de una Room es este Primitive con semántica de mensajes encima: la Room declara su propio tipo de Group, lo monta en el espacio de nombres de la Room, y deja que la propia membresía de la Room decida quién está dentro — así la lista del chat y la lista de la Room nunca pueden estar en desacuerdo.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

Nada de un chat está en el Primitive. La audiencia y la superficie send.* vienen de aquí; el autor, el hilo y el historial vienen de Messaging, y quién puede administrar la membresía es un derecho aparte (Access), ostentado por la Room.

El modelo

Qué declara un tipo de Group.

DeclaraQué es
nameel del propio tipo
membership modeexplicit — un miembro se agrega y se quita por una acción; o dynamic — la membresía se deriva de una regla, y quien satisface el predicado es miembro. Un Group es uno de los dos, nunca ambos
rulepara un Group dinámico: el predicado, en el mismo lenguaje de predicados que los predicados de acceso y las guardas de transición. Lo recalcula la plataforma; nadie consulta en bucle
capacityy el comportamiento al alcanzarla
entry ruleun predicado que puede rechazar la entrada, aparte de un Hook que también puede rechazarla
lifecycle behaviouren la primera entrada — created on first entry o created in advance; y en la última salida — closed on last exit o kept while empty. Declarado, nunca inferido de la observación
lifetimeopcional: al vencer, el Group se cierra con un Event

Qué vale para todo Group.

SiempreQué es
memberun Actor, nunca una Entity: un conjunto de Entities es una selección sobre Data. Un Group es un solo oyente masivo
statescreated → active → closed, y closed es terminal. Una instancia de Group tiene máquina; el tipo no la declara
event targetemite sobre él y sus miembros reciben; eso es lo que hace de la entrega masiva una señal en vez de un bucle
a group callson N llamadas, no una: el Group provee el direccionamiento y cada desenlace llega ligado al miembro del que vino. RPC exige exactamente un manejador lógico, así que una difusión que espera muchas respuestas son N llamadas, no una
partial outcomenunca se lee como uno completo: un miembro que falló, se pasó de tiempo o rechazó es una respuesta propia que lleva su Problem al lado de las que sí respondieron; un éxito parcial nunca se devuelve como uno total
recipientsnunca los enumera quien envía: la membresía decide, así que quien envía no tiene que conocer la composición de la audiencia
intra-group rolesno existen: un «dueño del Group» es un Actor que ostenta un derecho (Access), no una jerarquía guardada en la lista de miembros
first entry and last exitson distinguibles de las entradas y salidas de en medio — que es de lo que penden el comportamiento de ciclo de vida declarado y la inicialización de ronda
recomputationlleva el Delta: el Event de composición de un Group declarado por regla dice quién entró y quién se cayó, nunca la lista entera. La lista entera es una lectura, así que un suscriptor que solo quiere el cambio nunca paga por el listado
join and leaveson idempotentes: un cliente que se reconecta repite su entrada, obtiene la misma membresía y ningún error — el código de cliente nunca tiene que distinguir «ya estoy dentro» de «no me dejan entrar»
the interfacees la de un Group concreto, no solo la del tipo: te diriges a este escuadrón
the primitivese mantiene vacío: las reglas de entrada, la inicialización de ronda con el primer miembro, la intercepción de Events — eso son los módulos construidos sobre él. Una Room es un Group con reglas de asiento, una conversación de Messaging un Group con reglas de entrega, una pila de Matchmaking un Group que el emparejador drena, una lista de envío un Group sin regla alguna

Errores

  • Que una regla diga que no y que un Hook diga que no son respuestas distintas. Una regla de entrada falsa se lee como «entrada imposible»; un rechazo de Hook lleva la razón y el código del propio Hook. Un Hook de entrada al que no se puede llegar rechaza la entrada — la comprobación falla cerrada en vez de dejar pasar al Actor.
  • La membresía dinámica rechaza las ediciones a mano. Add o Remove sobre un Group declarado por regla es un rechazo de validación: el predicado es lo único que mueve esa lista, y la plataforma lo recalcula cuando cambian los datos que hay detrás.
  • Cuatro derechos, y ninguno implica a otro — entrar, administrar la membresía, publicar en el Group, leer la composición (Access). A un Actor al que le falta uno le llega un rechazo, nunca una operación nula en silencio; un Group que un predicado de visibilidad le esconde responde «not found» en su lugar, y un miembro siempre ve su propia membresía aun cuando la composición le esté cerrada.

Límites

  • Lleno es un conflicto, no una cuestión de derecho. Con capacidad + 1 la entrada se rechaza como conflicto — el Actor tenía permiso, el asiento no — y la misma llamada tiene éxito en cuanto se libera un asiento. La capacidad en sí está en el tipo (Capacity = 4 arriba); cuántos Groups pueden tener un Project y un solo Actor se fija con el capítulo de límites de la plataforma.
  • Una llamada de Group sobredimensionada se rechaza entera, antes de que se envíe nada — un reparto nunca se entrega a medias, así que ningún llamador tiene que detectar ese caso. El techo de tamaño llega con el capítulo de límites de la plataforma.

Flujo del usuario

Se forma una party, una llamada de reagrupamiento llega a cada miembro, un RPC repartido trae una respuesta por miembro, y el escuadrón hace cola como unidad.

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Extensibility

Todo escenario de la plataforma es una cadena de funciones registradas. Reemplaza un eslabón o envuélvelo. Esto es lo que significa «plataforma personalizable» en concreto, y es lo que reemplaza al código abierto: reemplazas los pasos propios de la plataforma por los tuyos, así que no necesitas nuestro código fuente.

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Un paso de la plataforma debe ejecutar tu lógica — declara el reemplazo de un eslabón con nombre mediante [Override(…)].
  • Necesitas comprobaciones o efectos secundarios alrededor de un paso — middleware Before/After ordenado que puede vetar o notificar.
  • Debe correr código en un horario, ante un Event, o por un webhook — los triggers te entregan un contexto tipado y ya analizado.
  • Debes saber qué va a ejecutarse realmente antes del deploy — simula una cadena y lee el orden resuelto.
  • Sáltatelo cuando la regla concierne a las escrituras de una sola Entity — un Hook de Data es la forma más liviana.

Quién hace qué

ActorEn esta página
backend-servicesobrescribe eslabones, envuelve pasos con middleware, escribe manejadores de trigger
operatorinspecciona cadenas, fija el orden, lee secretos, simula la resolución

De un vistazo

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.

El modelo

A qué te da la plataforma para engancharte.

TérminoQué es
registered functionun paso sobrescribible de la plataforma — «crear perfil», «resolver precio»
scenariola cadena ordenada que ejecuta un flujo de la plataforma: auth, entrada, compra
overridabilitysi un eslabón puede reemplazarse, solo envolverse, o es fijo
middlewareun manejador ordenado previo/posterior alrededor de un eslabón
triggerlo que arranca tu código: un Event, un horario, un webhook
secretun valor que tu manejador puede leer
invocationuna ejecución, con su traza
A scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

Qué declara un Hook.

DeclaraQué es
positionel paso con nombre al que se engancha
kindgatekeeper — una comprobación de admisión o una validación, y falla cerrado, así que el paso no corre cuando el propio Hook se rompe; u observer — un log, una notificación, un contador, y falla abierto, el paso corre, y el fallo se reporta igual en vez de tragarse. No hay valor por defecto
momentbefore — por delante de la validación, recibiendo el payload tipado, y puede mutar o rechazar; o after — una vez que el paso se confirmó, recibiendo la solicitud y el resultado, solo efectos secundarios, y nunca puede hacer fallar la operación ni alterar la respuesta
effectlo que el Hook hace, y no solo dónde se sitúa. Eso es lo que convierte «cuál de estos corre primero» de una pelea por números en una afirmación sobre el trabajo, y es por lo que el orden sobrevive a que alguien agregue un Hook al lado del tuyo
version and conditionun manejador que aplica a un Environment o a una audiencia es una versión declarada de ese Hook y no una rama dentro de su cuerpo, y es lo que conmuta el interruptor del panel

Tres maneras de enganchar código.

FormaÚsala para
atributo [Before(Step)] / [After(Step)]una regla sobre un paso con nombre — la mayoría de los Hooks
atributo [Override(Link)]reemplazar de plano la implementación de un eslabón
Extend.Scenario("…").Before("link", fn, order: n)envolver un eslabón dentro de una cadena, cuando importa el orden frente a otros middleware
SiempreQué es
all threese despliegan con playserv push
the two attribute shapesson lo que dibuja el panel, porque la Declaration lleva el nombre del paso o del eslabón al modelo enviado
the middleware formlleva un orden en su lugar, que es lo que necesita una cadena
assigning at startup(Scenario.OnX = fn) sigue disponible para un manejador que no necesita aparecer en el árbol administrativo
replacing one linkdeja intactos los eslabones de ambos lados, y ninguno de ellos sabe qué implementación respondió — el paso propio de la plataforma o el tuyo

Adónde va una llamada, y qué vale para toda ruta.

SiempreQué es
four directionsuna función en la nube · el backend externo del consumidor · el servidor de juego · otro declarado
the routerva dirigido por mensajes/señales; request-response es un adaptador sobre él y no su naturaleza
matchinges por el nombre declarado de la operación o de la señal y por nada más: ni la forma del payload, ni quien llama, ni la carga
a name registered twicees un defecto de la Declaration, rechazado cuando se declara el conjunto en vez de resolverse en el momento de la llamada
an unregistered nameresponde not found, en vez de caerse en silencio
the directionno es parte del contrato de la operación: mover un manejador entre direcciones no es un cambio rompedor
"the game server"se define por lo que es, no por quién lo hospeda — nuestra flota y el hospedaje propio de un estudio son una dirección, y la Declaration no lleva marca alguna de quién es dueño de la infraestructura
game-server RPCsse registran en el mismo enrutador: declarar uno es registrarlo, y no hay una segunda manera
orderingejecuta el middleware de arriba abajo, y donde un paso tiene más de una implementación el enrutador elige de izquierda a derecha por condición, respondiendo la versión marcada como predeterminada cuando nada coincidió

Qué es una restricción entre Hooks, y cuándo se comprueba.

Qué es
a named constraintun punto de extensión puede nombrar los efectos que restringe — una comprobación de anti-cheat debe preceder a una colocación, un recibo exige un cargo en este punto — y no restringir nada más
an unnamed effectqueda sin restringir, no rechazado: que un consumidor haga algo que nadie previó es para lo que existe el mecanismo, y un vocabulario cerrado convertiría eso en un rechazo en tiempo de registro
a violationes un defecto de la configuración, y el rechazo nombra los dos Hooks y la restricción que rompieron — ni un aviso, ni un reordenamiento silencioso
when it is checkeden cada acto que puede cambiar qué corre en un punto: registrar, desplegar, cambiar la disposición. Así que una disposición que llega a ejecutarse ya fue admitida
never re-checked at run timeeso sería una segunda respuesta a una pregunta zanjada, hecha en el único momento en que no se puede hacer nada al respecto
SiempreQué es
handlersson tipados a la entrada y a la salida: nada de dynamic, nada de bolsas de contexto. El handle de la plataforma es ambiente, y el contexto de la invocación — quien llama, el trigger, la traza — llega analizado
what an engine build seeslos Events que el escenario emite después, porque una sobrescritura o un middleware corren en la plataforma y un runtime de motor no es lugar para hospedar uno. Eso es lo que quieren decir las pestañas @na de las muestras de esta página al suscribirse a los Events resultantes
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.

Tómalo, cámbialo, publícalo. «Código abierto propietario» es un flujo de trabajo, no un eslogan — la lógica propia de la plataforma son funciones que puedes traerte, editar y volver a desplegar:

playserv functions pull matchmaking.match     # la implementación de la plataforma, como código fuente
# edita: ensancha la ventana de habilidad para el evento del fin de semana
playserv push                                 # tu versión se registra; la predeterminada queda como respaldo

La personalización aquí corre sobre un eje, y es lógica: los overrides, el middleware y las versiones de los que trata esta página.

El otro eje no existe. No puedes agregar tus campos a una Entity de la plataforma. Un jugador, uma Room, un registro de Leaderboard y un pedido son estado del sistema con máquinas propias, no el comienzo de tu modelo de datos. Tus datos son tu propia Entity, declarada en Schema as Code y atada al estado de la plataforma por un predicado — el dueño es este jugador, el alcance es esta Room — que es también lo que la mantiene tuya cuando el modelo propio de la plataforma se mueve.

Errores

  • Un nombre que no coincide con ningún manejador registrado responde not found — una llamada nunca se cae calladamente porque nadie estuviera escuchando.
  • Un nombre registrado dos veces, y un conjunto con dos implementaciones reclamando la misma condición, se rechazan cuando se declara el conjunto — en el deploy, no resueltos a cara o cruz en el momento de la llamada.
  • El rechazo de un Hook lleva el código y la razón del propio Hook, así que «una regla del juego dijo que no» nunca llega pareciendo un fallo de transporte.
  • Un Hook que falla se comporta según su tipo declarado — fail-open o fail-closed — y cuál de los dos es se declaró en vez de inferirse de lo que pasó.
  • Un Hook no puede cambiar lo que ya está reclamado: ni el dueño, ni el destino, ni el tablero o la conversación a la que iba dirigida una llamada. Corrige entradas y devuelve un veredicto.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • El plazo de ejecución de un Hook — pasado él, el comportamiento de fallo que declara su tipo.
  • Hooks en una misma posición, e implementaciones de un mismo método — registrar otro se rechaza.
  • La profundidad de anidamiento de «un Hook invoca una operación que tiene Hooks» — un rechazo declarado, nunca agotamiento de recursos.
  • El tamaño del contexto que se le pasa a un Hook — truncar está prohibido, así que se rechaza el registro en vez de que un manejador reciba medio contexto.

Flujo del usuario

Una compra, del clic del jugador a través de la cadena personalizada hasta el ping al escuadrón. fraud-check es el manejador propio del estudio sobre Before("grant"), no un módulo de la plataforma; Commerce dibuja la misma compra desde su propio lado.

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

Una cadena con sobrescrituras y middleware puede resolverse y leerse antes de que nada corra. El orden resuelto es inspeccionable en el panel y desde el código.

Lecciones y recetas: un leaderboard en Tanks usa los Hooks de este módulo; un torneo diario corre a través de este módulo.

Your game's model

Schema as Code

Declara el modelo en código, envíalo, recibe tipos de vuelta. La vía del desarrollador hacia el schema: el panel administrativo y el código escriben el mismo modelo, y la generación de código cierra el círculo para cada motor.

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

Cuándo usarlo

  • Tu modelo de datos debería vivir en código y revisarse como código — declarar, schema diff, schema push.
  • Los tipos del motor no deben desviarse nunca del modelo desplegado — schema codegen regenera el C++ de Unreal y el C# de Unity.
  • Un cambio rompedor debe ser legible y cancelable antes de ejecutarse — proponer → planificar → aplicar.
  • Reutilizas un mismo paquete (Stat, Interactable) entre proyectos — decláralo una vez como preset.
  • Sáltatelo cuando un operador solo ajusta valores en el panel administrativo — el cambio de modelo igual vuelve como diff al código.

Quién hace qué

ActorEn esta página
schema-authordeclara Entities/parts/enums en código, hace diff y push
operatorrevisa el resumen del panel, propone y aplica migraciones
cila tubería de build, corriendo bajo una clave backend-service: hace push en un merge y regenera después los tipos del motor

De un vistazo

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # declaraciones locales contra el schema desplegado
playserv schema push       # con una precondición de revisión — nada de sobrescrituras a ciegas
playserv schema codegen    # regenerar los tipos Unreal C++ / Unity C#

El push es un paso explícito. No se sube nada cuando guardas un archivo: una Declaration llega al modelo desplegado solo cuando corre playserv push (o schema push), desde tu máquina o desde CI, y lleva la Revision contra la que se hizo el diff. Una edición en el panel es visible para ti como cualquier otra desviación — schema diff te la muestra frente a tus Declarations. Verla es automático; moverla en cualquiera de las dos direcciones es un comando que ejecutas a propósito.

El modelo

Qué lleva una Declaration.

LlevaQué es
keyel nombre estable por el que se la direcciona. Un renombrado en código es un renombrado, no un borrar-y-crear
kinduna Entity, una part o un enum, declarados en código
ownership modeseed — el código crea el registro si no está y un push repetido deja los valores en paz, así que la consola administrativa es su dueña de ahí en más; o managed — el código es su dueño siempre, cada push lleva los valores a lo declarado, y las ediciones desde la consola administrativa se rechazan en vez de aplicarse y perderse en el siguiente push
presetopcionalmente: un paquete reutilizable — un preset de Entity como stats o world-objects — declarado una vez y aplicado como un tipo. No introduce ninguna clase nueva de Declaration, y un preset que necesitara una sería un hueco en el contrato antes que un preset más grande

Qué promete un push.

SiempreQué es
matchinges por clave, nunca por símbolo: un push repetido tras renombrar el símbolo deja un registro y no dos
idempotencyse sigue de eso — un push repetido no es un segundo registro
the reportdice exactamente qué va a cambiar antes de aplicar, y qué sobrescribió después
origines distinguible: un registro creado por un push desde código se diferencia de uno creado en otro lado
the revisionviaja con él, y un push aterriza entero o no aterriza

Qué promete la generación de código.

SiempreQué es
regenerationocurre después de cada push, y los tipos generados nunca se editan a mano: regenerar y luego hacer diff no produce cambio alguno
namingsigue a la Declaration allí donde se haya escrito — el campo Rarity de Item se vuelve UPSItem::Rarity en Unreal e Item.Rarity en Unity
the two directionsno se bifurcan: lo que se declara en código aparece en el panel, y lo que un operador escribe en el panel hace diff limpio contra el código — que es lo que hace de schema diff una respuesta completa y no la mitad de una

Qué promete una migración.

SiempreQué es
when one is requiredun cambio de Declaration persistente que reescribe valores existentes, y un cambio persistente rompedor no puede publicarse sin una
what it declaresuna versión, una vista previa, una aplicación ordenada, un rollback ante fallo, y un desenlace de finalización observable
coexistencemientras dos versiones de datos están vivas, las lecturas y las escrituras declaran qué versiones aceptan — el runtime nunca infiere compatibilidad a partir de nombres de campo

Errores

  • Un llamador sin el rol recibe forbidden y el modelo desplegado queda intacto: un rechazo nunca es un push parcial. Ese es un rechazo distinto de una Revision rancia, que es precondition_failed y significa que el diff se calculó contra un schema que desde entonces se movió — vuelve a hacer diff y push de nuevo.
  • Un valor fuera de una cota declarada se rechaza en la escritura, nunca se recorta, y una cadena pasada de su longitud declarada, lo mismo. Recortar produce un valor que es válido y equivocado, y el costo cae sobre el soporte en vez de sobre quien llama: un rechazo cuesta un viaje de ida y vuelta.
  • Una secuencia UTF-8 inválida se rechaza en la escritura en vez de repararse.
  • Un cambio persistente rompedor sin migración declarada no puede publicarse en absoluto.

Límites

Los techos con forma de Declaration se comprueban en tiempo de declaración — en el deploy o en la publicación — y no en el primer uso, allí donde el síntoma en runtime no se vería como un rechazo. Esa es la regla que enuncia el capítulo de límites de la plataforma, y es por lo que un schema que se publica es uno que ya encaja. Los números en sí llegan con ese capítulo.

Flujo del usuario

Un campo nuevo, de su Declaration en código a los tipos de motor regenerados.

Your game's model

Entity

El módulo en el que se apoya todo lo demás. Una Entity es una Declaration de schema hecha crecer con aspectos vivos: datos 0..*, estados 0..*, RPC 0..*, Events 0..*, Hooks e historial de cambios. Los mapas ligan obstáculos a Entities, Collision liga un aspecto de transformación, Stats es un preset, los objetos del mundo son un preset más una máquina de estados.

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity se monta en la raíz, así que room.Entity<Door>(id) y playserv.Entities<KeyDef>() se sitúan directamente sobre la raíz y no detrás de un espacio de nombres. Se construye sobre tres Primitives — Events, RPC y Data — y sobre nada más. Collision, Locomotion y Prediction se sitúan por encima: cada uno liga a un aspecto, no a la Entity entera, que es por lo que un contacto puede disparar una transición sin que el módulo de colisión sepa nada de permisos.

Cuándo usarlo

  • Un objeto del mundo necesita comportamiento, no solo campos — máquinas de estados, RPC con permisos y Events sobre una sola Declaration.
  • Puertas, trampas, pickups: las transiciones deben dispararse desde Events de cliente, contactos de Collision, o umbrales de Stat sin código de Room.
  • Quieres objetos de juego como creaciones de una línea — aplica o deriva presets como world-objects.
  • Una disputa necesita el estado exacto del mundo en el momento del disparo — lee una instancia en un sim_time pasado, dentro de la ventana declarada.
  • Sáltatelo cuando la cosa no tiene identidad — un valor que solo vive dentro de otra cosa, como el texto de la placa de una puerta, es un campo de un aspecto, no una Entity propia. Todo lo que sí se direcciona es una Entity: Data es la mecánica de debajo, y ninguna ruta de tabla la esquiva.

Quién hace qué

ActorEn esta página
schema-authordeclara Entities, aspectos, máquinas de estados, presets
every actorconsulta, se suscribe, llama RPC de Entity, lee estado

De un vistazo

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

Un aspecto es la unidad declarada, no el campo: motion lleva su propia cadencia y su propia máscara, info lleva otras, y un campo pertenece a exactamente uno de ellos. Eso es lo que permite que un preset adjunte un grupo entero de una vez, y lo que permite que Collision ligue al único aspecto que lleva una transformación sin ver nada más de la Entity.

Tres reglas gobiernan la máquina de ese bloque:

  • Un nombre con punto anida un nivel. open.blocked se asocia con open por sí solo, así que la transición close_requested declarada en open aplica dentro de él sin repetirse. Mientras la máquina está en open.blocked está en open — una comprobación de estado para open es verdadera, y OnEntered("open") se disparó al entrar y no vuelve a dispararse para el subestado.
  • Un temporizador declarado en un estado corre sólo mientras ese estado es el actual. Salir de opening descarta su AfterSeconds, y volver a entrar arranca uno nuevo.
  • Una guarda es un predicado declarado sobre los campos propios de la Entity, en el mismo lenguaje que un predicado de fila de acceso. La lógica que necesita código es un Hook, no una guarda.

Las rutas de campo se escriben como en el modelo enviado. Los predicados y las rutas de consulta nombran los campos tal como los envió la Declaration — motion.jammed, gate.state, info.name — sea como sea que cada binding los escriba localmente.

Quién puede llamar al RPC. Un RPC de Entity nombra el derecho que necesita igual que toda operation: un atom de derecho (entity × execute) más un predicado de fila que dice qué instancias cubre (Access & Roles es dueño de ambos).

En la DeclarationQué significa
caller in entity.roomcualquier Actor en la Room en la que está la puerta, corra el build que corra. La proximidad no forma parte de eso: cuán cerca tienes que estar para recibir los deltas de la puerta es una regla de Visibility sobre la política de sync del aspect — ancho de banda, no permiso, y ensanchar una vista nunca ensancha un derecho
Actorla identidad de quien llama, el mismo objeto que devuelve whoami
caller.Inventoryel handle de Inventory para ese jugador, disponible dondequiera que ese módulo esté montado

playserv push es lo que vuelve real a una Declaration. Schema as Code es dueño del paso: hace diff de tus Declarations contra el modelo desplegado, lleva la Revision contra la que se hizo el diff, y rechaza en vez de sobrescribir si el schema desplegado se movió. Un re-push que rompería instancias ya vivas pasa por proponer → planificar → aplicar, así que el plan es legible antes de que cambie nada.

En el cliente, la Entity es la API:

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

La señal locked es el Event propio de la puerta: su destino es el de la Declaration — esta instancia — así que todos los suscritos a la puerta la oyen y ninguna lista de destinatarios viaja con el envío.

El modelo

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

Un tanque, una puerta, una barra de Stat y una misión son todos Entities. Difieren en qué aspectos llevan y en nada más, que es lo que permite que todos los demás módulos se construyan sobre este.

Qué declara una Entity.

DeclaraQué es
aspectun grupo de campos con nombre declarado como un todo, con su propia política de sincronización y su propia máscara de acceso. Una Entity lleva varios, y ningún campo está en dos
state machineestados anidables un nivel, transiciones y guardas; varias por Entity
triggerqué dispara una transición — las cuatro fuentes están abajo
entity RPCun verbo que sobresale de la Entity, declarado dentro de la vista con el átomo de derecho que necesita
entity eventuna señal que la Entity emite, entregada a quien se suscriba a esa instancia
hookprevio y posterior, sobre operaciones de datos y sobre transiciones, desplegados como funciones en la nube. Extensibility declara el orden, la forma del veredicto y qué hace un fallo
history tracksi la vista conserva siquiera la ventana instantánea
refun enlace a otra Entity que lleva su id y nunca su key, así que renombrar una clave nunca rompe un enlace. Include lo trae junto con la página

Qué dispara una transición, y ninguna de las cuatro es tu código corriendo en una Room.

FuenteCómo se dispara
client event or RPCcualquiera declarado — el RequestOpen de arriba dispara open_requested
collisionun contacto o la entrada a un volumen disparador — trampas, placas de presión — a través del aspecto al que liga Collision
data thresholddeclarado sobre un Stat, 0 HP → death, aplicado por el orden de Hooks y no por código en una Room
timeAfterSeconds sobre un estado es un disparador declarado, no una corrutina: corre sobre el reloj de simulación de la Room, avanza con sim_time, se detiene mientras la Room no simula, y borrar la instancia termina sus máquinas y sus temporizadores pendientes con ella

Qué puede hacer una selección.

EjeQué es admisible
filter y sortsolo campos declarados — no hay handle de tabla, y una selección se direcciona por Entity, acotada a una Room o al Project
includeuna ref declarada, traída junto con la página
pagingpor cursor opaco: ni un desplazamiento, ni un id de fila, y su significado no sobrevive a un cambio de versión. Devuélvelo tal cual, nunca lo analices
accesslos predicados se aplican antes de paginar, así que una página nunca lleva huecos donde estarían las filas ocultas
livesuscribirse a una selección la mantiene viva, con miembros que entran y salen a medida que cambian sus datos
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

Qué vale para toda Entity.

SiempreQué es
a selectiones un conjunto de Entities, no un Group: los miembros de un Group son actores y existen para que una señal llegue a todos, mientras que una selección es una lectura que resulta quedarse viva
a transition requestsigue siendo una solicitud: corren las guardas de la máquina, corre el predicado de fila, y una transición que la máquina no declara se rechaza con invalid_state_transition en vez de ignorarse calladamente
pushing past a guardes una operación distinta con un átomo distinto — entity × administer, que ninguna clave de cliente ostenta por defecto
historyes solo la ventana instantánea: estados recientes indexados por sim_time, acotados por una profundidad declarada, y una lectura fuera de ella se rechaza en vez de responderse con el valor más cercano. El historial ramificado — líneas de tiempo alternativas, deshacer, repetición de una partida entera — queda fuera de alcance, porque tendría que prometer valores de coma flotante reproducibles y las reglas de tipos no lo hacen
the boundary of a changees una Entity, y ahí es donde se detiene el «todo o nada». Dos Entities cambiadas por un mismo llamador — debitar una billetera, agregar el ítem — pueden observarse aplicadas a medias. Así que un par que debe aparecer junto no son dos Entities: guarda ambos valores en una instancia y la frontera hace el trabajo. Echar mano de un Hook para «hacerlo atómico» no lo logra, porque el Hook corre alrededor de un cambio y no a través de dos
a declared method with no implementationes un estado terminado, no uno a medio configurar. Llamarlo responde con un veredicto que lleva la razón legible por máquina «sin implementación» — ni un rechazo, ni un éxito con un resultado vacío. Un rechazo significaría que la llamada no debió hacerse; aquí sí debió, y lo único que no ocurrió es la decisión
a name never declaredes un desenlace distinto de un nombre declarado sin implementación: el primero es un rechazo de validación, el segundo un veredicto, y el código puede distinguirlos
an unimplemented callno se desvanece — que alguien la invocó es observable para el estudio. Qué forma toma esa observación deliberadamente no es parte del contrato, así que construye sobre el hecho de que es observable, no sobre una línea de log
creating an instancelleva el átomo entity × write: una función en la nube, un servidor dedicado y un master-client lo ostentan por defecto, y un cliente corriente solo donde un rol se lo otorgue — en todos los bindings, no solo en Unreal

Presets. Un preset es un paquete nombrado de aspectos, máquinas, Hooks y límites aplicado a una vista. No agrega conceptos nuevos — todo lo que trae un preset podrías declararlo a mano, que es por lo que un preset que necesita una clase nueva de Declaration es un hueco en el modelo antes que un preset más grande. Se publican cinco: stats, abilities, projectiles, drops, world-objects, y Entity presets declara cada uno por completo. Un estudio deriva los suyos a partir de ellos, en código o en el panel — Crate es world-objects más stats:

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Errores

  • Una instancia que no existe, y una que un predicado esconde, ambas responden not found — así que un rechazo nunca le dice a quien llama que algo existe pero no es suyo.
  • Un campo no declarado, anidados incluidos, y un campo obligatorio sin valor, son rechazos de validación que nombran el campo.
  • Una transición que la máquina no declara se rechaza como invalid_state_transition, nunca se ignora calladamente. Empujar una máquina más allá de sus guardas es una operación distinta con un átomo distinto — administer, que ninguna clave de cliente ostenta por defecto.
  • Un desajuste de versión es un fallo de precondición: vuelve a leer y decide de nuevo.
  • Una clave tomada es un conflicto.
  • Una escritura en nombre de un jugador que no nombra al jugador es un rechazo de validación y no una escritura atribuida a nadie.
  • Sin permiso responde forbidden, con lectura y escritura distinguidas.
  • Una lectura de historial fuera de la ventana instantánea se rechaza en vez de responderse con el valor más cercano — «no hay datos para ese Tick» y «aquí va el valor aproximado» son hechos distintos.
  • Una instancia almacenada pasada del límite de tamaño es un conflicto que nombra el campo culpable y el tamaño medido; el techo se alcanza por acumulación, así que el acercamiento a él es observable antes de la escritura que falla.

Límites

Todo límite se declara junto con lo que pasa en su frontera. Los números son por Project y se fijan por Project; el comportamiento de abajo es fijo desde ya.

LímiteEn la frontera
aspectos por vista · máquinas por vista · profundidad de anidamiento dentro de un aspectola Declaration se rechaza en playserv push, nunca se trunca en silencio
tamaño de instancia almacenadala escritura se rechaza como conflicto, nombrando el campo y el tamaño medido; acercarse al límite es observable antes del rechazo
tamaño de página de una selecciónla página se corta al tope y «hay más» sigue siendo verdadero — nunca recibes una página corta que parezca final
ventana de historial instantáneouna lectura fuera de la ventana se rechaza, no se responde con el valor más cercano
tasa de cambio sobre una instanciaun rechazo por límite de tasa que lleva cuánto esperar

Flujo del usuario

Una puerta, de su Declaration al tintineo que oye el jugador.

Your game's model

Herencia y composición

Los módulos se apoyan unos en otros, y nada de eso es herencia de clases. No hay módulo base del que derivar ni jerarquía que extender — los módulos forman un grafo. Esta página es lo que honestamente significa aquí «herencia», y los seis mecanismos que hacen el trabajo en su lugar.

Qué significa aquí la herencia

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

La palabra cubre cuatro mecanismos distintos, y vale la pena separarlos por nombre.

  • Los RPC de una Entity son parte de la Entity. No existen en ninguna otra parte — ni en algún padre, ni en un registro compartido. Si un método pertenece a una puerta, está en la puerta. Consulta Entity.
  • Un preset es un paquete nombrado, no una clase base. Stats, abilities, projectiles, generadores de drops y objetos del mundo son presets de entity — paquetes de aspectos que aplica una vista de Entity, y por eso viven en una sola página como Entity Presets y no como cinco módulos. Aplicar un preset agrega aspectos; no pone tu tipo debajo de nada.
  • Sobrescribir un paso de la plataforma es un atributo sobre tu reemplazo. No heredas del nuestro; declaras el tuyo, y las versiones se eligen por condición con el valor predeterminado de la plataforma como respaldo. Consulta Extensibility.
  • Un módulo toma prestado a otro a través de un decorador que estrecha o enriquece la interfaz prestada, con la implementación intercambiable detrás. El caso trabajado es un chat dentro de una Room, en Groups.

Y lo que no es: no hay jerarquía de clases de módulos, porque un árbol solo admite ramas y las funcionalidades reales las cruzan. El matchmaking reserva asientos en Rooms; los drops colocan objetos a través del mapa; un leaderboard se alimenta de un Hook sobre el cierre de una Room. Eso es un grafo, y es deliberado.

Los seis mecanismos

Cada uno se declara con un atributo al lado de aquello que compone — la misma regla declarativa que gobierna todo lo demás en el SDK.

Puntos de montaje, como un sistema de archivos. Un módulo se monta en la raíz — componiendo varias interfaces en una sola superficie — o dentro de un espacio de nombres. Un segundo módulo que reclame un punto de montaje ocupado se rechaza en tiempo de montaje, nunca en la primera llamada. El mecanismo está en Under the Hood.

Visibilidad léxica. La visibilidad de los nombres sigue al anidamiento: una Declaration global es visible dentro de un módulo, una local nunca se filtra hacia arriba. Lo que un módulo emite es una pregunta aparte y se declara en su propio contrato — un módulo conoce solo los Events que declaró, o los que se registraron con él.

El encapsulamiento como contrato. Un módulo nunca sabe quién lo llama ni por qué. Lo que expone y lo que emite es toda su historia pública, y nada sobre quien llama cambia su comportamiento salvo la concesión de quien llama.

Reutilización por decorador e inversión de control. Un módulo se refiere a otro a través de un decorador en vez de meter mano dentro de él, y la implementación detrás de la interfaz es intercambiable. Este es el mecanismo que te deja reemplazar uno de nuestros módulos por el tuyo sin que los módulos que dependen de él lo noten.

Las Declarations hacen crecer la API. Declara un Event sobre un Group y aparece group.Send.ChatMessage(…) con su contrato; declara el dato members y aparece un getter tipado. La Declaration es la entrada de la generación de código — que es también por lo que lo que versionas es la Declaration y no el código generado.

Tres ejes de direccionamiento saliendo de un mismo módulo. Todas las instancias, una instancia, y el administrador de una instancia son tres APIs distintas, no una API con una bandera. Enunciado por completo en Groups.

Argumentos implícitos, y por qué no son magia

Dentro de una Entity nunca pasas la Entity. El receptor, quien llama y el contexto ambiente se ligan automáticamente, porque los tres ya están determinados por dónde se hizo la llamada y quién la hizo — pasarlos sería pedirte que repitas algo que la plataforma ya sabe, y darte la oportunidad de decirlo mal. El mecanismo está en RPC.

Your game's model

Entity presets

Un preset es un paquete nombrado de aspectos de Entity — datos, estados, RPC, Events, Hooks — empaquetados para un caso de juego. Aplicas un preset, ajustas sus números, o derivas el tuyo. Aplicar uno agrega aspectos a tu tipo; no pone tu tipo debajo de nada — un preset no es un módulo y no tiene nada propio de lo que heredar. Stats, abilities, projectiles, tablas de drop y objetos del mundo son cinco presets, no cinco subsistemas: la misma Declaration, la misma sincronización, el mismo orden de Hooks.

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

Cuándo usarlo

  • Una cosa de tu juego lleva números que se recortan, se regeneran y disparan una transición en sus cotas.
  • Una acción necesita costo, cooldown, fases y efectos, alcanzables desde un solo verbo de cliente.
  • Algo sale en vuelo, y su impacto debe juzgarse con justicia para un tirador con lag.
  • El botín debe salir de probabilidades con peso que se reproduzcan exactamente cuando un jugador disputa un drop.
  • El mapa tiene mobiliario — puertas, botones, trampas, destructibles — con estados que deben sobrevivir a una entrada a mitad de ronda.
  • Sáltate los presets cuando una Entity son datos sincronizados a secas. Declara los campos y para ahí.

Quién hace qué

ActorEn esta página
schema-authordeclara stats, abilities, projectiles, tablas de drop, objetos del mundo
room-ownerajusta los números de los presets, tira las tablas de drop, crea objetos del mundo
playerlanza abilities, hace disparos, recoge botín, interactúa con objetos

De un vistazo

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

El modelo

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

Un preset no introduce nociones nuevas. Todo lo que agrega es expresable con los medios que Entity ya da — aspectos, máquinas, Hooks, persistencia. Un preset que necesitara una clase nueva de Declaration sería un hueco en el contrato, no una razón para hacer el preset más grande. Esa es toda la prueba para saber si algo pertenece aquí.

Qué da cada preset, y dónde se ajusta.

PresetQué le da el contratoDónde se ajusta
statsun aspecto de características numéricas con cotas, regeneración y modificadores, más un Hook al alcanzar un umbral — 0 HP se vuelve una transición de máquina en vez de un if en tu códigola Declaration del campo; los números quedan editables en vivo en el panel
abilitiesun aspecto con un conjunto de abilities, una máquina de fases de aplicación, y costo y cooldownla Declaration de la ability
projectilesun tipo con persistencia runtime, un aspecto de balística, y un Event de impactola Declaration del projectile — un cambio de atributo cambia el modelo de vuelo
dropsun aspecto de tabla de drop con pesos, y un Hook posterior a la muertelas entradas y los pesos de la tabla
world objectsuna máquina de estados de un objeto interactivo, y un aspecto de la condición de interacciónla Declaration del preset, o por instancia en la creación
inventoryun tipo con dueño y una ref a un ítem de catálogo, un aspecto de pila con un incremento, y un tope por dueño con desbordamiento declaradola Declaration del tipo

La tabla es una línea por preset porque una línea es lo que cambia entre ellos. Lo que comparten está abajo, y Inventory es el único que además tiene página propia.

Qué vale para todo preset.

SiempreQué es
where it sitssobre una Entity, como aspectos: sus datos se sincronizan como cualesquiera otros, sus estados son estados de Entity, sus RPC son RPC de Entity, y sus Hooks corren en el orden de Hooks de la Entity
tuninges configuración en vivo y no un redespliegue, que es por lo que el panel muestra una cota de Stat, un cooldown y un peso de drop en un solo árbol
declaring onees un acto de schema, no una llamada de jugabilidad — que es por lo que su rechazo es de una clase distinta que los rechazos con los que se topa un jugador, y ambos están en Errores más abajo
deriving your ownes composición, no herencia de clases: Crate es world-objects más stats, y la cosa derivada sigue siendo aspectos sobre una Entity

Errores

  • Declarar un Stat, una ability, un projectile, una tabla de drop o un objeto del mundo es un acto de schema — fn o adm. Una clave de jugador o de cliente que intente uno recibe forbidden, y nada queda declarado ni medio declarado. Ese es un rechazo distinto de aquellos con los que se topa un jugador dentro de una llamada que sí tenía permitido hacer — en cooldown, no puede pagar, falta un item:key.bronze — cada uno de los cuales lleva su propio código.

El resto de los rechazos de un preset son los de Entity — un preset no introduce nociones, así que tampoco introduce rechazos, y repetirlos aquí le daría al lector dos sitios donde buscar una sola respuesta. Dos cosas son específicas de los presets mismos:

  • Un preset parcialmente llenado es un fallo de validación en el deploy. Un preset lleva un conjunto coherente: media Declaration se rechaza antes de publicarse en vez de comportarse raro en una partida.
  • Un preset no puede marcarse con una propiedad que su propia mecánica contradice — un aspecto para el que el cliente no tiene reglas no puede declararse predecible, y eso también se atrapa en el deploy.

Límites

Los techos son los de Entity — aspectos por tipo, máquinas por tipo, el tamaño de la instancia almacenada, la tasa de cambios sobre una instancia. El único que un preset declara por sí mismo es el tope por dueño que lleva un preset con dueño, con uno de tres comportamientos de frontera y sin valor por defecto: refuse · redirect a un depósito de dueño declarado · discard with event. Los números llegan con el capítulo de límites de la plataforma.

Flujo del usuario

Un proyectil, del gatillo apretado a la caja a los pies del tirador. Participan cuatro presets — ability, projectile, stat y drop-table — y ninguno de ellos es un módulo que montes.

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Rooms

Una Room es una sesión de juego; a la plataforma no le importa qué la hospeda. Una abstracción cubre un servidor dedicado por partida, un gran mapa compartido partido en capas lógicas, una Room hospedada por master-client y un minijuego hospedado en el backend. El interior de la Room es nuestro; tú conduces una Room desde afuera.

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Tu juego tiene sesiones — partidas, lobbies, mazmorras, carreras — y algo debe ser dueño de su ciclo de vida, su membresía y sus reconexiones.
  • Hospedas en servidores dedicados, en el master-client de un jugador, o en el backend mismo, y necesitas enrutar jugadores hasta ahí.
  • Un mapa compartido debe ejecutar muchas sesiones lógicas — capas, acotadas por Visibility.
  • Los jugadores entran a mitad de sesión y deben ver la verdad actual — el estado de la Room a la llegada, después tráfico en vivo.
  • Una conexión caída no debe costar el asiento — la ventana de tolerancia del template (45s en battle) retoma la misma membresía.
  • Sáltatelo cuando una funcionalidad es puramente petición/respuesta sobre registros — Data a secas ya lo cubre.

Quién hace qué

ActorEn esta página
room-ownerregistra Rooms a lo largo del proceso; sobre una instancia de Room — la interfaz administrativa por instancia: parchea la configuración en vivo, expulsa, bloquea, difunde, descarta
entry-validatoracepta o rechaza solicitudes de entrada con un código y una razón
room-visitornavega, entra con datos, se reconecta dentro de la ventana de tolerancia, sale
spectatorentra sin disputar; recibe difusiones y tráfico en vivo
match-organizerreserva asientos que cuentan para la capacidad; una reserva vence en el plazo del template (90s en battle)

De un vistazo

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Dondequiera que se haya escrito, el template enviado está versionado y es editable en el panel, así que live-ops reajusta un tipo de Room sin volver a desplegar el motor. Hospedar Rooms construidas a partir de él es la misma superficie alcanzada por un rol distinto, y tanto un servidor dedicado de Unreal como un master-client ostentan todo eso — registrar, hospedar varias por proceso, parchear la configuración en vivo, expulsar, publicar, descartar.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Cliente:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

El modelo

Una Room es un Group con reglas, y no es una Entity. La membresía viene de Groups; su estado de sistema es de nivel de plataforma, mientras que el estado del juego vive en Entities acotadas a ella. Y ningún código de consumidor corre dentro de una Room — en ninguno de los dos modos de autoridad.

Qué declara un tipo de Room.

DeclaraQué es
capacityen asientos, y un asiento es la unidad de capacidad separada de la membresía: puede reservarse antes de una entrada y mantenerse a lo largo de la inactividad. Una reserva es limitada en el tiempo, con un plazo declarado tras el cual el asiento se libera sin entrada
visibilityenumerable · por nombre o código · oculta
creation modeuno de tres, y el modo on first join está obligado a declarar un Hook de inicialización
two independent timeoutsel timeout de inactividad — cuánto puede callar un participante; y el TTL de Room vacía — cuánto sobrevive una Room sin nadie dentro. Dos preguntas distintas, por lo tanto dos Declarations
rejoin windowdentro de ella un retorno restaura la misma membresía y el mismo asiento, en vez de crear un participante nuevo
authority modeour simulation o external authority, y no hay valor por defecto
trust in a reported outcomepara autoridad externa: aceptarlo · comprobarlo con un Hook · no aceptarlo. Otra vez sin valor por defecto
behaviour when the host dropsesperar la ventana de tolerancia · cerrar la Room · admitir un reemplazo
map instance and world strataopcionalmente, qué instancia ocupa y qué estratos dentro de ella
room-scoped entitiescuáles de las Entities del estudio tienen el alcance de la Room — expresado por un predicado, no por un mecanismo nuevo

Las dos máquinas.

DeEstados
una Roomcreated → open → closed → torn down, donde torn down es terminal y closed significa sin entradas nuevas y no desaparecida
una membresíaactive ⇄ inactive → departed, con departed terminal para esa membresía

Qué vale para toda Room.

SiempreQué es
losing a connection and leavingson eventos distintos, y el desenlace de la ventana es observable: «volvió» y «la ventana venció» son distinguibles, así que un cliente nunca queda adivinando cuál de los dos pasó
no replayuna reconexión retoma desde el estado de la sesión; el módulo no promete los Events del hueco
a spectatorno es un participante degenerado: presente, sin ocupar ningún asiento, y fuera del listado al que se le dice «los jugadores» — de lo contrario toda operación sobre el listado llevaría una condición
no in-room rolesun dueño de Room es un Actor que ostenta un derecho (Access), no un rango en la lista de miembros
presencetiene historial, el listado no: quién entró, se cayó, volvió y se fue queda guardado; los cambios del listado no son un segundo historial
the interfacees la de una Room específica: te diriges a esta Room, no solo a su tipo
three axes, not twola API que abarca toda Room (navegar, registrar, listar); la API por Room que llama cualquier miembro (entrar, salir); y una interfaz administrativa por instancia — expulsar, bloquear, parchear la configuración, cerrar, descartar esta Room — abierta a quien ostente el rol de admin o de host de esa instancia y no a la membresía
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

Los dos modos de autoridad.

ModoQuién ejecuta el Tick
our simulationnuestra implementación de Room y sus módulos
external authorityun proceso que corre nuestro SDK, dentro de la Room bajo un rol autoritativo: el servidor de juego del estudio, o el cliente de un jugador como master-client

Qué decide el modo, y qué no.

Qué es
the linese traza por rol, no por de quién es el proceso. Un servidor dedicado es el mismo cliente sin la renderización; lo que lo separa de la máquina de un jugador es la confianza, no la construcción — que es también por lo que el peer-to-peer no necesita un tercer modo, siendo una Room en modo de autoridad externa cuya autoridad es un host cliente
what is identicallas reglas de entrada, la presencia, la reconexión y toda Declaration, en los tres. Lo único que difiere es qué proceso ostenta la autoridad y cuánta de ella se le concede
the room does not moveningún código de consumidor corre dentro de una Room en ninguno de los dos modos, y el estado de sesión y el persistente se quedan con nosotros en ambos. Un master-client es un miembro que ostenta un rol autoritativo: el Tick se computa ahí, la Room no vive ahí
trust in the outcomees una Declaration aparte sobre el tipo de Room — aceptarlo · comprobarlo con un Hook · no aceptarlo, y sin valor por defecto — en vez de una propiedad del modo

Hospedar una Room.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
SiempreQué es
the handlees el mismo objeto que usa la pestaña de cliente: no hay bootstrap de host ni handle exclusivo de servidor. Responde a estas llamadas porque el rol del Actor las incluye en runtime — un servidor dedicado o un master-client corriendo bajo una clave de host (Access)
registrationtoma una clave de idempotencia, porque un timeout sobre ella sería irrecuperable de otro modo: repite la llamada con la misma clave y recibes de vuelta la misma Room, no una segunda cuya dirección nadie conoce

Entrada, presencia y reconexión.

Qué es
entryes una solicitud validada: quien entra aporta datos en la entrada y el Hook de entrada acepta o rechaza con un código y una razón. Esos datos son a partir de lo que el miembro se inicializa — en battle el loadout que se lleva en la entrada es con lo que nace el Tank del miembro
seats and reservationsMatchmaking toma un asiento por el plazo de reserva del template — 90 segundos en battle — y el cliente entra después directamente. Las reservas cuentan para la capacidad, y el vencimiento libera el asiento con un Event en vez de en silencio
a drop is not a leaveun miembro desconectado conserva su asiento por la ventana de tolerancia (45s en battle) y se reconecta a la misma membresía; el vencimiento la convierte en una salida, y el Event lleva cuál de las dos fue. A quien vuelve un segundo tarde se le dice que la Room está viva y la membresía no — una respuesta distinta de «no existe tal Room», a propósito
late join is state, not a journalquien entra recibe el estado actual de la Room y después tráfico en vivo. Los Events enviados mientras estaba fuera no se reproducen, y tampoco los que se perdió un miembro que vuelve: todo lo que tenga que sobrevivir al hueco es estado — la mina que puso un jugador es una Entity acotada a la Room, no un mensaje MinePlaced que alguien tiene que atrapar

Errores

  • Una Room que no existe o que un predicado esconde, y una Room desmantelada, responden not found — un rechazo nunca revela una Room que no puedes ver.
  • Capacidad agotada es un conflicto, y los asientos reservados cuentan como ocupados; vale repetir cuando se libere un asiento. Cerrada a entradas es igualmente un conflicto, y vale repetir si abre.
  • Una entrada rechazada por una regla es un conflicto; rechazada por un Hook lleva el código y la razón del propio Hook, así que «una regla del juego dijo que no» nunca llega pareciendo un fallo de transporte.
  • La ventana de retorno venció es un conflicto: entra como participante nuevo, con asiento nuevo.
  • Una reserva caducada es un conflicto: toma otra.
  • Una instancia de mapa no disponible o inexistente es un rechazo de validación.
  • Un traslado que la Room de destino rechazó es un conflicto, y qué hacer depende de su razón.
  • Crear por encima del límite de Rooms responde como límite de tasa o como conflicto según cuál límite haya sido.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • La capacidad de una Room — una entrada se rechaza como conflicto, con los asientos reservados contados como ocupados.
  • Rooms por Project — la creación se rechaza como conflicto.
  • Rooms por Actor — la creación se rechaza, y las Rooms ya creadas nunca se descartan para hacer sitio.
  • La tasa de creación de Rooms — un límite de tasa con un plazo.
  • El timeout de inactividad de un participante — una salida forzada con un Event y una razón declarada.
  • El TTL de Room vacía — desmantelamiento con Event; desactivable en el tipo de zona persistente.
  • El plazo de la reserva de un asiento — liberación con Event.
  • Rooms sobre una instancia de mapa — crear sobre una instancia ocupada se rechaza a menos que el tipo haya declarado ocupación compartida.
  • El tamaño del payload de un Event de Room — la publicación se rechaza antes del envío, nunca se trunca.

Flujo del usuario

Una partida en un servidor dedicado, del sign-in del jugador al HUD que muestra quién entró.

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

Quién ve qué, y qué máquina lo ejecuta

Dos preguntas que suenan como una. Quién ve qué trata de un cliente: qué porción del estado de la Room le llega a qué jugador. Qué máquina lo ejecuta trata de un host: qué proceso es dueño de una Entity, y cuál será el dueño después. La palabra que las confunde es replication: en un motor de juego suele nombrar la primera, y aquí nombra la segunda.

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
Si te refieres aLee
qué cliente recibe qué estado, y cuánto de élVisibility, con Data y Prediction
qué máquina es dueña de la Entity, y qué pasa cuando muereWhat Survives Losing a Host

Se declaran en dos lugares distintos

Ninguna de las dos se configura en tiempo de ejecución, y no comparten Declaration.

Se declara enQue nombra
quién ve quéel aspect — Data, Visibilityel predicado de visibilidad, el tope de objetos y su orden, qué áreas vecinas se ven, y el modo de entrega
qué máquina lo ejecutael tipo de Room — Roomsel modo de autoridad, cuánto se le cree a una autoridad externa sobre un desenlace, y el comportamiento cuando el host cae

También se diferencian en lo que pasa si no dices nada. Un aspect sin regla de visibilidad propia se entrega en el paquete compartido, que es el valor por defecto y es el correcto para una Room chica. Un tipo de Room que no nombra modo de autoridad es rechazado: no hay valor por defecto, porque nada puede elegir por ti entre nuestra simulación y una externa.

The live game

Visibility

Con 40 jugadores un snapshot de la Room entera está bien. Con 200 no lo está. Una zona de visibilidad decide quién recibe qué, como un predicado declarado y no como un interruptor que accionas por objeto. La difusión y los paquetes por Actor son dos modos de entrega declarados de un mismo modelo, así que moverse entre ellos es configuración y no una reescritura. Es una optimización de canal y no un permiso — para eso, consulta Access.

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

Un solo modelo declarado — el predicado, las capas, los niveles de detalle — se lee de dos maneras. Moverse entre ellas es configuración, no una reescritura, porque ambas son lecturas de la misma Declaration.

DifusiónPaquetes por Actor
Envíala Room entera, a todosa cada jugador solo la porción que seleccionan sus reglas
Sirve parauna Room chica; este es el valor por defectouna multitud, donde el tamaño del paquete debe seguir siendo predecible
Lee la Declarationuna vez, para la Roompor Actor

Lo que no debe filtrarse está ausente del paquete en vez de escondido en el cliente — nunca se envía, lo que lo vuelve una propiedad de seguridad y no de ancho de banda.

Cuándo usarlo

  • Tus Rooms se pasan de la difusión de Room entera — 200 jugadores necesitan flujos de vecindario por cliente, no cada Delta.
  • El estado no debe filtrarse: la niebla de guerra y los campos solo-del-dueño deberían no enviarse nunca, no esconderse en el cliente.
  • Varias sesiones comparten un mapa y no deben verse entre sí — una capa es un predicado más.
  • El tamaño del paquete debe ser predecible en una multitud — topa los objetos y declara el orden, para que «los N más cercanos» sea una promesa y no un accidente de la densidad.
  • Un jugador en una frontera debe ver más allá de ella — declara qué áreas vecinas son visibles, porque el valor por defecto es solo la propia y de lo contrario una frontera se lee como un muro de vacío.
  • Sáltatelo cuando la Room es chica — el modo de entrega por paquete compartido ya lo cubre.

Quién hace qué

ActorEn esta página
schema-authordeclara el predicado de visibilidad, el tope de objetos y su orden, qué áreas vecinas son visibles, y el modo de entrega
anyse suscribe y recibe lo que la zona admite; puede bajar el tope de objetos para sí mismo dentro de las cotas declaradas

De un vistazo

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

El modelo

Qué declara una regla de visibilidad.

DeclaraQué es
predicatela regla misma, en el mismo lenguaje de predicados que los predicados de acceso y las guardas de transición. Un radio, una instancia de mapa, un equipo y la propiedad son casos particulares de un predicado, no mecanismos aparte — hay exactamente uno de esos lenguajes en el contrato
object cap y su ordenuna regla puede topar la cantidad de objetos, y entonces el orden de selección se declara en vez de inferirse: «los N más cercanos» es un predicado más un ordenamiento por distancia más un tope. Un predicado solo no puede expresarlo, porque un predicado responde «¿califica esta fila?», no «¿cuál de las que califican está más cerca?»
neighbouring areassi son visibles los objetos de una Room o instancia de mapa vecina, y cuáles exactamente. Nunca implícito: sin Declaration el área es aquella en la que está el destinatario
delivery modeshared packet — lo mismo para todos, barato en CPU; o per-actor packet — cada uno el suyo según su zona, caro en CPU y necesario con poblaciones grandes

Qué vale para toda zona.

SiempreQué es
visibilityno es un permiso: lo que la zona esconde puede estar disponible por permiso, y al revés. Lo primero es una optimización de canal, lo segundo es seguridad — y confundirlos significa que un ajuste de niebla de guerra ensancha permisos en silencio, o que las ACL se usan para ahorrar ancho de banda y los derechos empiezan a depender de la distancia
the recipientpuede bajar el tope: dentro del máximo declarado y nunca por debajo del mínimo declarado, porque un predicado es el mismo para todos aquellos cuyo contexto coincidió, mientras que el tamaño de un paquete es problema del destinatario
truncationes observable: el destinatario se entera de que el paquete se cortó y por qué orden. Truncar en silencio está prohibido — es indistinguible de que no haya más objetos
degradationestá declarada: cuando se agota el presupuesto para ensamblar paquetes por Actor la plataforma cae al paquete compartido tal como está declarado, en vez de empezar a perder destinatarios arbitrariamente: peor, pero de una manera conocida, en vez de una filtración indistinguible de un bug del juego
packet shapees una promesa débil: el tamaño y la composición de un paquete no deberían permitir inferir la existencia de objetos escondidos, y eso es deliberadamente más débil que un DEBE — esconder por completo los metadatos del flujo a volúmenes reales es inalcanzable. Donde importa la filtración de la existencia, usa permisos, no la zona

Errores

  • Un destino de suscripción no declarado es un rechazo de validación.
  • Sin permiso para suscribirse responde forbidden o not found según si la existencia del destino es un secreto — el rechazo mismo no debe filtrar qué está rechazando.
  • Una posición de reanudación que no se puede analizar es una petición mal formada, no un reinicio silencioso desde ahora.
  • Una suscripción que la plataforma cerró, y una cuenta de suscripciones agotada, son ambas conflictos.
  • Ampliar una vista es fn. Otorgar una vista de excepción y fijar los niveles de detalle se le responden forbidden a una sesión de jugador, y su vista queda intacta: un cliente espectador no puede ampliar su propia concesión.
  • Una instancia que la vista del llamante excluye responde not found, la misma respuesta que una que no existe — un forbidden confirmaría que algo está detrás del muro.
  • Leer el costo de paquete por Actor es fn adm — una función cloud o el panel, nunca un cliente preguntando cuánto cuesta que lo observen.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • El costo de un paquete por Actor — al agotarse, degradación declarada al paquete compartido con un aviso, nunca pérdida arbitraria de destinatarios.
  • Objetos por regla — topados con un orden declarado y una marca de truncamiento observable.
  • Suscripciones por Actor — se rechaza una nueva y las existentes continúan.
  • Tamaño del Delta — el Delta se parte en vez de truncarse, y la partición es observable.
  • La tasa de envío — una cota superior, no una garantía.

Flujo del usuario

Una regla de radio convierte una Room de 200 jugadores en flujos de vecindario por cliente.

«¿Quién ve esto?» y «¿qué ven?» son ambas consultables, porque la sesión de depuración en la que no puedes responderlas es la cara. El costo de paquete por Actor es una lectura de primera clase, en código y en el panel.

The live game

Qué sobrevive a la pérdida de un host

Un host muere a mitad de partida. La partida no. Esta página trata del segundo significado de la palabra «replicación» — qué máquina es dueña de una Entity, y qué máquina lo es después. El primer significado, qué cliente recibe qué estado, es Visibility con Data y Prediction. Quién ve qué, y qué máquina lo ejecuta es donde se distinguen los dos.

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

El estado de la Room no se copia entre hosts

Una Entity tiene exactamente un dueño a la vez, y ninguna segunda máquina guarda una copia viva lista para tomar el relevo.

Dos copias aceptando el mismo disparo tendrían que ponerse de acuerdo sobre el orden en que aterrizaron los dos disparos. Ponerse de acuerdo sobre un orden treinta veces por segundo, entre máquinas, es consenso — y el consenso pone latencia exactamente donde un juego no la tolera. Un dueño único no tiene ese problema, y todos los mecanismos de abajo existen para hacer sobrevivible a un dueño único, no para sortearlo.

Lo que sí se replica es la presencia: qué Actor está en qué nodo. Ese es un hecho pequeño y de cambio lento, así que el enrutamiento puede conocerlo en todas partes sin pagar por ponerse de acuerdo sobre nada que se mueva.

El estado declarado se guarda fuera del host

El estado declarado no es privado del proceso que lo alberga. Se le toma un snapshot en un intervalo declarado, de modo que un reemplazo puede retomar desde el último snapshot cuando el host anterior deja de responder, y el jugador vuelve a entrar por la ventana de tolerancia corriente de Rooms.

Tres cosas se siguen de ello, y son su forma honesta:

  • El reemplazo tiene el estado entero, pero tal como estaba en el snapshot. Completo, no actual. Lo que cuesta un failover es el juego entre el último snapshot y la pérdida, y el intervalo es lo que fija ese peor caso.
  • La continuidad del Tick no se lleva a través de un cambio de autoridad. Una mudanza que realiza la plataforma preserva el estado de Tick del participante; un reemplazo de la autoridad no lo promete. Rooms es donde se declaran ambas, junto con qué pasa cuando la ventana de tolerancia vence.
  • Todo lo que guardaste solo en actores del motor se va con el proceso. Nunca estuvo declarado, así que nada fuera de ese host lo tuvo jamás.

Un deploy es la misma ruta, sin la pérdida

Drenar un host — dejar de colocar Rooms nuevas ahí, dejar que las sesiones en curso terminen o se traspasen, y luego soltarlo — es la ruta de failover ejecutada a propósito y con aviso. Por eso desplegar sin matar sesiones vivas no es un segundo mecanismo que construir y en el que confiar: es este, arrancado deliberadamente en vez de por una caída.

El host de la Room se entera igual que se entera de cualquier cosa: la plataforma avisa por adelantado de que una Room va a cerrarse o a traspasarse por una razón de su propio lado.

Qué pasa una vez que la ventana vence está declarado, y no hay valor por defecto. Un tipo de Room cuya autoridad vive fuera de la plataforma nombra uno de tres desenlaces para perderla — esperar una ventana declarada, cerrar la Room, o admitir una autoridad de reemplazo. Dejarlo sin decir no es una opción que ofrezca la Declaration, porque la alternativa es el fallo que existe para prevenir: una Room con una autoridad muerta que sigue aceptando entradas y reteniendo asientos, mostrándole a cada participante una sesión viva en la que no pasa nada.

Qué máquina es no forma parte de tu superficie

Nunca nombras un nodo. Quien crea una Room no elige dónde corre, y ninguna operación toma un host como argumento — la colocación es de la plataforma, y sigue siendo de la plataforma para que pueda mover una Room sin que tu código esté escrito contra dónde solía estar.

Si hospedas Rooms tú mismo — un servidor dedicado o un master-client — vale lo mismo con un agregado: se te avisa de que vayas cerrando, y terminar o traspasar tus sesiones dentro de la ventana de tolerancia te toca a ti. Rooms es donde un host se registra para ese binding, y Authority es por qué el host ostenta solo los derechos que se le concedieron.

The live game

Matchmaking

Meter a un jugador en la Room correcta. Los tickets describen al jugador y filtran a los demás. El matchmaker resuelve una colocación, reserva un asiento, y el tráfico del juego fluye después directamente a la Room.

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

El matchmaker está en el camino una vez, para decidir dónde perteneces. No está en el camino de la partida: su desenlace es una colocación y una reserva de asiento limitada en el tiempo, y desde la entrada en adelante el tráfico del juego va derecho a la Room. Así que una cola ocupada nunca se convierte en un juego ocupado.

Cuándo usarlo

  • Necesitas jugadores enrutados a Rooms por criterios declarados — modo, región, rango — y no una lista de lobbies hecha a mano.
  • Los criterios de emparejamiento deben venir de datos de la plataforma, no de lo que afirma el cliente: sella el rango en el Hook previo al encolado.
  • Las colas deberían ensancharse con el tiempo del lado del servidor mientras el cliente conserva un ticket y nunca consulta en bucle.
  • Las parties deben caer juntas en una misma partida — un Group entra entero o no entra.
  • Corres un matchmaker externo y solo necesitas terminar su decisión en colocación + reserva de asiento.
  • Sáltatelo cuando los jugadores eligen una sesión ellos mismos — el navegador de Rooms y Join ya lo cubren.

Quién hace qué

ActorEn esta página
playercrea y cancela su propio ticket, y entra como parte de una party
match-organizerdeclara las colas del matchmaker y su relajación; lee los resultados de colocación
backend-servicesella criterios de confianza antes del encolado; ejecuta decisiones de matchmakers externos

De un vistazo

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

Los criterios que no deben confiarse al cliente se sellan en el Hook previo al encolado:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

La lectura es la lectura por lista de dueños de Leaderboards — la misma que usa una cohorte de amigos — y cada fila que devuelve lleva el rango de ese dueño en la tabla completa. El Hook puede pedir la fila de otro porque el predicado del tablero deja que lo haga una función en la nube; una sesión de jugador que hace la misma pregunta obtiene solo la suya.

El modelo

Qué lleva un ticket, y las dos partes no se reducen la una a la otra.

ParteQué esQuién le cree
self-descriptionlas propiedades declaradas del participante — puntuación, modo, idioma, mapa elegidonadie sin una comprobación: es lo que afirma quien llama
requirementun predicado que el resto debe satisfacerla plataforma, porque es quien lo aplica

El participante de un ticket es un Actor o un Group — un Group entra entero, y eso es lo que es una party. Su ticket es indivisible: el Group entra entero a un listado o no entra, porque partir un Group sería una promesa distinta y no hay ninguna.

Qué declara un tipo de cola.

DeclaraQué es
propertiespor nombre y tipo. Una propiedad no declarada aquí se rechaza en un ticket como fallo de validación en vez de ignorarse
roster sizeun mínimo, un máximo, y un paso de compatibilidad — el múltiplo con el que un listado es aceptable, para que equipos «de cinco» signifique cinco, y no cualquier número entre dos y diez
requirement ladderun conjunto ordenado de predicados con ventanas: cada peldaño es un requisito más ancho y un tiempo tras el cual el matchmaking sigue adelante. La relajación es una Declaration, nunca lógica arbitraria en un manejador
the predicate languageel mismo que usa todo lo demás, y su vocabulario incluye las propiedades propias del ticket — «una puntuación a ±100 de la mía» es expresable. Sin eso el modelo de dos lados no funciona en absoluto, porque las condiciones relativas son toda su gracia
mutualitysi es aceptable un listado en el que A acepta a B mientras B no acepta a A. No hay valor por defecto
ticket lifetimetras el cual el ticket transiciona a expired con un Event
outcomeRoomPlacement — una referencia a una Room más reservas en ella, para un juego simultáneo; o RosterSet — el listado solo, sin Room y sin reservas, para uno asíncrono en el que el oponente está fuera de línea

Los estados de un ticket. created → queued → matched · cancelled · expired, los tres últimos terminales.

SiempreQué es
one live ticket per participant per queueun segundo es un conflicto, no una segunda solicitud — lee el existente
the reason for a pairinges observable: llega al Event de matchmaking y al historial. Para los algoritmos publicados eso es el peldaño de la escalera; una implementación que sobrescriba puede no tener peldaños, y entonces la razón es un valor opaco que ella declara — pero siempre hay una
expiryes un desenlace, no un error: «un listado no se armó en el tiempo declarado» es una finalización normal entregada como el desenlace del ticket
the outcomellega por suscripción: no por consulta en bucle. El matchmaking toma segundos y decenas de segundos, así que consultar en bucle convertiría la espera en carga que crece con la longitud de la cola — el cliente conserva un ticket y nunca vuelve a preguntar
losing the connection cancels the ticketdeclarado en vez de inferido: un ticket es una solicitud de jugar ahora, y emparejar a un jugador ausente empeora el listado para todos los demás
matchedes atómico: para RoomPlacement, o bien el listado queda emparejado y cada participante tiene una reserva, o los tickets se quedan en la cola. Para RosterSet el resultado atómico es el listado solo

Errores

  • Un segundo ticket en la misma cola es un conflicto; no lo repitas, lee el ticket existente.
  • Una propiedad no declarada, o un requisito que nombre una, es un fallo de validación — no una omisión silenciosa que afloraría después como «no se encontraron oponentes».
  • La cola está suspendida responde unavailable, no forbidden: los derechos de quien llama están intactos y la situación es temporal, así que reintentar con espera creciente es lo correcto.
  • La Room para el resultado no se puede crear es igualmente unavailable, con espera creciente.
  • La reserva falló es un conflicto que vale reintentar — el ticket se queda en la cola.
  • Un ticket que no se encuentra o que es de otro, y un Actor que un predicado no admite a la cola, responden ambos not found, así que un rechazo no revela ni el ticket ni la cola.
  • «Un listado no se armó» nunca es un error — consulta el vencimiento más arriba.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Tickets en una cola — la creación se rechaza como conflicto, y los tickets existentes no se desalojan para hacer sitio.
  • El tiempo de vida de un ticket — una transición a expired con un Event.
  • Peldaños de la escalera — una Declaration con demasiados se rechaza en tiempo de declaración.
  • Tamaño del Group en un ticket — el ticket se rechaza como fallo de validación.
  • Propiedades declaradas por tipo de cola — rechazadas en tiempo de declaración.
  • Tasa de creación de tickets — un rechazo por límite de tasa con un plazo.
  • Retención del historial de matchmaking — pasado el periodo una entrada es ilegible por el periodo declarado.

Flujo del usuario

Del sign-in a estar parado en la Room de la partida, con el rango sellado del lado del servidor. El viaje empieza en Auth porque un ticket tiene dueño: sin sesión no hay a quién encolar.

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Map

El mundo estático: límites, terreno, obstáculos, y «¿adónde pueden ir las cosas?». El modelo físico es deliberadamente mucho más simple que el visual: primitivas con una huella y una altura, capas con reglas, y una sola consulta de posición válida que todos los demás módulos reutilizan.

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Necesitas un mundo estático — límites, terreno, obstáculos — que el servidor pueda consultar, no solo dibujar.
  • Los spawns, los drops y las decoraciones deben caer en sitios legales: una consulta RandomPosition basada en reglas, sin atajos.
  • Las arenas deberían regenerarse por partida — una Seed declarada reproduce el mismo mapa en un reporte de bug.
  • Las cajas y los muros se rompen y vuelven — destructibles con HP y temporizadores de reaparición.
  • Los bots y los projectiles necesitan respuestas de raycast y línea de visión contra el conjunto de obstáculos.
  • Sáltatelo cuando el mundo es puramente visual y ningún código de servidor pregunta adónde pueden ir las cosas.

Quién hace qué

ActorEn esta página
schema-authordeclara mapas, primitivas de obstáculo, destructibles, capas y sus reglas
room-ownerliga un mapa a una Room; pide posiciones de spawn; lanza raycasts
operatorcoloca o quita obstáculos y capas desde el panel

De un vistazo

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter y Destructible son generadores de colocación, no tiradas en runtime. Un generador se resuelve cuando se publica la versión del mapa: las cuarenta rocas se vuelven cuarenta primitivas declaradas, y la versión publicada lleva las primitivas, no la regla. La misma Seed da por lo tanto las mismas cuarenta rocas en la partida, en la repetición y en el reporte de bug — y los límites de geometría se comprueban una vez, sobre ese conjunto resuelto, antes de que la versión llegue a un Environment.

La consulta que hace todo lo demás:

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

El modelo

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

Dos capas, declaradas por personas distintas.

CapaQué lleva, y quién la declara
staticterreno con altura, primitivas de obstáculo, límites y lugares — contenido escrito
dynamicobstáculos traídos por Entities en runtime: puertas, destructibles, plataformas. Un destructible es por lo tanto una Entity con ciclo de vida, estados y dueño, y se vuelve mapa solo en la parte en que trae un obstáculo — una roca estática se declara en el mapa, una puerta es una Entity que trae uno. Estos no tienen ciclo de vida propio aquí: le pertenece a la Entity

Qué declara un mapa.

DeclaraQué es
key y versionun mapa es contenido escrito: declarado en código, direccionado por key, y versionado — la versión es parte de lo que referencia una Room. Cambiar la geometría de una versión publicada está prohibido; una edición es una versión nueva
terrainun campo de alturas — una grilla regular con un paso declarado, y el paso es un límite de precisión declarado, así que una consulta de altura responde con arreglo a él y no exactamente. El terreno puede estar ausente: una arena en el vacío es lícita
obstaclesun conjunto cerrado de primitivas — caja, esfera, cápsula, envolvente convexa con un límite de vértices declarado. Una malla de triángulos arbitraria no se acepta, que es la condición de que una comprobación del lado del servidor sea posible siquiera
passability kind por obstáculoimpasable · pasable para una clase declarada · bloquea solo la línea de visión. Una misma primitiva sirve de muro y de arbusto, y la diferencia se declara en vez de modelarse dos veces
boundsel volumen fuera del cual una posición es inadmisible
world strataestratos espaciales declarados dentro de un mapa — suelo, subsuelo, aire. Son Declarations de geometría y de direccionamiento
locationslugares o áreas con nombre — un punto de spawn, una zona de captura, un corredor. Un lugar responde dónde, nunca qué pasa: no lleva lógica de juego
placement generatoropcionalmente, una regla que produce primitivas — una cantidad, una separación mínima, un área, una semilla. Se resuelve cuando se publica una versión, determinísticamente por la semilla, y de ahí en más el mapa lleva primitivas y no una regla

Un estrato del mundo y una instancia de mapa nunca son sinónimos.

Qué es
world stratumuna Declaration dentro del mapa — suelo, subsuelo, aire
map instanceuna copia de runtime independiente del mapa publicado. Las instancias comparten la geometría publicada inmutable y tienen obstáculos dinámicos independientes y listados de Entities independientes. Una Room ocupa una instancia y puede seleccionar estratos dentro de ella

Qué vale para toda consulta.

SiempreQué es
one geometric canontoda la geometría está en el canon de coordenadas declarado de la plataforma, y la precisión de cada campo geométrico se declara en el campo
the world modeles una simplificación: la geometría del servidor no es el modelo artístico, y no está obligada a serlo
an answer names its instance and its momentuna consulta se responde con la capa estática del mapa más los obstáculos dinámicos de la instancia por la que se preguntó, y declara el momento para el que es verdadera — los obstáculos dinámicos cambian, así que la respuesta es un snapshot
the valuesson gestionados, no sembrados: la geometría no es el ajuste diario de un diseñador: una edición desde la consola administrativa se rechaza en vez de conservarse en silencio
movement and contactno se resuelven aquí: responde qué es el espacio; si una posición es admisible y cuál es la respuesta le pertenece a Collision, y aplicarlo a Locomotion

Errores

  • Un mapa, una versión o una instancia que no se encuentra responde not found, y lo mismo una versión retirada — repetir no sirve de nada.
  • Publicar geometría cambiada bajo una versión existente es un conflicto: haz una versión nueva.
  • Los fallos de publicación caen en la declaración, en el deploy, nunca en runtime — un mapa por encima del límite de primitivas, una envolvente convexa por encima de su límite de vértices, y una malla arbitraria como obstáculo son todos rechazos de validación antes de que se publique nada.
  • Una consulta de altura fuera de los límites no es un error — es la respuesta declarada «fuera de los límites», y es distinguible de «dentro de un obstáculo», porque un cliente da media vuelta en un caso y rodea en el otro.
  • La tasa de consultas excedida responde en la categoría de límite de tasa con un plazo.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Primitivas de obstáculo por mapa, vértices de envolvente convexa, resolución del campo de alturas, tamaño de los límites, lugares por mapa — todos ellos se rechazan en la publicación, no en tiempo de consulta: un mapa que se publica es un mapa que ya encaja.
  • Instancias de mapa por mapa — crear otra se rechaza como conflicto; las instancias existentes nunca se liberan para hacer sitio.
  • Versiones conservadas — se retira la versión en desuso más vieja, y nunca una versión bajo una Room viva.
  • La tasa de consultas al espacio — un límite de tasa con un plazo.

Flujo del usuario

Un lanzamiento aéreo programado le pide al mapa un sitio legal, y un jugador conduce hasta ahí para recogerlo. La drop-table es un entity preset — una Declaration sobre una Entity, no un módulo que montes.

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Collision

Liga una transformación al mapa de obstáculos; declara qué hace el contacto. Collision corre dentro de la simulación de la plataforma. Tú declaras cuerpos, capas y respuestas, y te suscribes a los contactos.

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

Cuándo usarlo

  • Las Entities en movimiento deben resolver contactos del lado del servidor — deslizar, detenerse, rebotar — sin una rutina de desvío escrita a mano.
  • La jugabilidad reacciona al roce: los pickups se recogen por superposición, los volúmenes disparadores accionan una máquina de estados de Entity.
  • Locomotion y los projectiles necesitan resolución barrida contra el conjunto de obstáculos del mapa.
  • Las vistas previas de colocación o el apuntado necesitan «¿esto cabría aquí?» y consultas de superposición de volúmenes.
  • Sáltatelo cuando nada se encuentra físicamente — la jugabilidad de petición/respuesta sobre registros es Data a secas.

Quién hace qué

ActorEn esta página
room-ownerdeclara cuerpos, capas y respuestas; consulta superposiciones y contactos

A qué Rooms aplica esto. Este módulo corre donde la plataforma avanza la simulación — Rooms declaradas con Host = "Backend". Si tu propio game server es dueño de la simulación (PlayServ como metaservidor), el movimiento, la colisión y la predicción se quedan del lado del motor, y esta página describe la alternativa alojada en la plataforma, no un requisito.

De un vistazo

La forma, la capa y qué hace el contacto están todas sobre el cuerpo mismo — nada declara pares de capas desde lejos:

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

Un contacto es un Event, y los módulos se suscriben a él. No hay Hook sobre un contacto: para cuando uno existe el paso ya lo resolvió, así que no queda nada que rechazar. Donde un estudio necesita reglas distintas, sobrescribe las comprobaciones de admisibilidad y de trayecto como implementación — consulta Extensibility — y la respuesta sigue siendo declarada.

El modelo

Qué declara un cuerpo.

DeclaraQué es
shapeuna primitiva de un conjunto cerrado — esfera, cápsula, caja — con dimensiones declaradas. Una malla arbitraria no se ofrece, la misma restricción y la misma razón que el modelo del mundo del servidor en Map
where it livessobre un aspecto, junto con la transformación: esa es la unidad de política, y un cuerpo comparte un mismo destino con su transformación
how its path is checkedstepwise — se comprueba la posición final del paso, rápido, y un cuerpo veloz atraviesa un obstáculo delgado; o swept — se comprueba el segmento entre posiciones, más caro, y el efecto túnel queda excluido dentro de un paso. Declarado, nunca elegido por una implementación a partir de la velocidad: que un projectile pueda volar a través de un muro es una propiedad del juego, no una optimización
areas it participates indentro de qué volúmenes se lo cuenta
its relation to the art modelno se exige ninguna: un cuerpo es una simplificación, y la divergencia respecto del modelo artístico es admisible dentro de cotas declaradas

La respuesta se declara sobre un par — la clase de pasabilidad de un obstáculo × un tipo de cuerpo — y sale de un conjunto cerrado:

RespuestaQué significa
stopel movimiento cesa en la última posición admisible
slideel movimiento continúa a lo largo del obstáculo con la componente que sea admisible
bouncela dirección se refleja y la velocidad se multiplica por un coeficiente declarado
dampel movimiento continúa con la velocidad multiplicada por una fracción declarada
passel obstáculo no afecta al movimiento, pero el contacto sigue siendo observable
cease to existla Entity termina — un projectile contra un muro

Los coeficientes son valores declarados y no computados a partir de masas y materiales — no hay ni unas ni otros en este contrato.

Qué vale para toda comprobación.

SiempreQué es
every pairtiene una respuesta: un par que falta es un defecto de la Declaration, rechazado en el deploy en vez de encontrado en combate
the response tablees legible por el cliente: la misma tabla con la que computa la autoridad, así que un cliente y un servidor a los que se les da una Declaration responden un contacto igual
reproducible within one authority, not across platformsla misma entrada en el mismo orden da el mismo resultado dentro de un proceso y un build. Resultados idénticos bit a bit en plataformas y builds distintos no están prometidos, y un modelo de red construido sobre la suposición de que las colisiones se computan igual en todas partes está construido sobre arena
simultaneityestá declarada: cuando dos cuerpos en movimiento colisionan dentro de un mismo paso, el orden de resolución está declarado y es determinista. El orden de recorrido del almacenamiento, el orden en que llegó la entrada y el azar no pueden ser su fundamento
one contact, one factun contacto entre dos cuerpos es observable por ambos lados como un solo hecho con un solo identificador, no como dos Events independientes
extension points sit on the step, not on a contactantes del paso la transformación puede cambiarse, después de él hay observación. Un contacto ya ocurrió, así que no hay nada que rechazar; reglas distintas son una sobrescritura declarada de las comprobaciones de admisibilidad y de trayecto, y tal sobrescritura está obligada a estar disponible también para el cliente
the moduleno mueve nada por sí mismo: responde si una posición es admisible y cuál es la respuesta; aplicar eso le corresponde a Locomotion
a contact is an eventque es por lo que los módulos se suscriben en vez de acoplarse: la máquina de estados de una trampa liga una transición a la entrada a un volumen disparador, drops recoge por superposición, y los projectiles resuelven impactos con el barrido de este módulo

Errores

  • «Inadmisible» es una respuesta, no un error, y nombra cuál de tres razones: fuera de los límites, ocupado por un obstáculo estático, u ocupado por el cuerpo de otra Entity. Un cliente reacciona a las tres de forma distinta — dar media vuelta, rodear, o esperar — así que colapsarlas en un «no» costaría comportamiento.
  • Los fallos de Declaration caen en el deploy, nunca en el primer contacto: un cuerpo con una forma fuera del conjunto cerrado, un cuerpo sobre un aspecto sin transformación, y un par sin respuesta declarada se rechazan todos en el deploy. Una colisión ocurre en combate, y un fallo en runtime allí se observa como un muro que se desvaneció.
  • La Entity o el lugar no se encuentra responde not found, y repetir no sirve de nada.
  • La tasa de comprobaciones excedida responde en la categoría de límite de tasa, con el plazo antes del cual reintentar no sirve de nada.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Cuerpos en una Room — declarar otro se rechaza como conflicto; los cuerpos existentes nunca se quitan para hacer sitio.
  • Contactos por paso — el excedente nunca se descarta en silencio: o bien se rechaza el paso, o bien el orden de corte está declarado.
  • El tamaño de un cuerpo frente al paso de la grilla del mapa — rechazado en el deploy, porque un cuerpo más chico que el paso del campo de alturas se cae a través del terreno, y eso no puede ser una sorpresa en runtime.
  • Áreas dentro de las que puede estar un cuerpo — un excedente se rechaza en el deploy.
  • La tasa de comprobaciones por Actor — un límite de tasa con un plazo.
  • Cuerpos en una respuesta de «quién está en esta área» — truncados por un orden declarado, y la marca de truncamiento es obligatoria.

Flujo del usuario

Un volumen disparador, una máquina de estados y una puerta: los Events de contacto hacen todo el cableado. La placa y la puerta son objetos del mundo — entity presets, no módulos que montes.

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Locomotion

Tú declaras cómo se mueve una cosa; nadie escribe un integrador. Un modelo de movimiento convierte entradas secuenciadas en movimiento autoritativo, integrado con Collision, registrado para Prediction, y modificado por buffs, debuffs y terreno.

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Las Entities se mueven bajo la entrada del jugador — tanques, personajes, vehículos — y el movimiento debe ser autoritativo del servidor.
  • Prefieres declarar límites de velocidad, aceleración y tasa de giro antes que escribir un integrador.
  • La jugabilidad empuja cuerpos por ahí: knockbacks con Impulse, Teleport, y modificadores tipo barro con duraciones.
  • El movimiento debe sentirse instantáneo: el mismo modelo declarado avanza en el servidor y en el bucle de Prediction.
  • Sáltatelo cuando las posiciones cambian solo a saltos discretos — un campo sincronizado en la Entity ya lo cubre.

Quién hace qué

ActorEn esta página
schema-authordeclara modelos de movimiento, restricciones y ligaduras
room-owneraplica impulso, teletransporte y modificadores desde el host
playerenvía entradas secuenciadas; lee el estado de movimiento

A qué Rooms aplica esto. Este módulo corre donde la plataforma avanza la simulación — Rooms declaradas con Host = "Backend". Si tu propio game server es dueño de la simulación (PlayServ como metaservidor), el movimiento, la colisión y la predicción se quedan del lado del motor, y esta página describe la alternativa alojada en la plataforma, no un requisito.

De un vistazo

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

La entrada del cliente es una intención secuenciada. La plataforma hace avanzar el movimiento:

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

Verbos del lado del servidor:

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

El modelo

Qué declara una Entity para poder moverse.

DeclaraQué es
movement modeluno del conjunto publicado — steering, tanque, personaje, vehículo, vuelo — como varias implementaciones de un mismo paso, con condiciones de elección declaradas y un valor por defecto. El paso es una función pura (pose, input, dt) → pose
parametersvalores declarados que el cliente puede leer; sin ellos la predicción diverge sistemáticamente. Son seed — un diseñador los ajusta y un deploy no debe perder las ediciones en silencio — mientras que los límites sobre los que descansa el anti-cheat pueden ser managed, y entonces una edición desde la consola administrativa se rechaza
limitsvelocidad, aceleración y frenado máximos, giro máximo por entrada, el multiplicador de marcha atrás, y una velocidad de rotación aparte para las partes. El giro por entrada se declara aparte de la velocidad de rotación a propósito: uno acota un salto instantáneo, el otro una tasa continua, y son defensas distintas
behaviour on stale inputstop, continue until a declared deadline, o continue indefinitely. No hay un valor por defecto de «seguir como antes» — un jugador al que se le cayó la red seguiría conduciendo
pose tolerancequé tan lejos puede estar una pose afirmada de la del servidor, y puede diferir por estado: quieto, en movimiento, y justo después de un respawn son tres tolerancias distintas
step rate and catch-up capcon qué frecuencia corre el paso, y cuántos pasos pueden darse de una vez cuando el servidor va atrasado
impulse kindscada uno con su magnitud y su manera de decaer

Qué vale para todo paso.

SiempreQué es
the module ownsla posición y la orientación de una Entity a lo largo del tiempo, y nada más. El historial de esas posiciones lo guarda Entity y no esto, así que «dónde estaba el jugador hace 300 ms» tiene exactamente una respuesta en vez de dos búferes con periodos distintos
authorityes del servidor: bajo el modo our simulation un cliente envía una intención, nunca un resultado
collisionsno se resuelven aquí: le pregunta a Collision si una posición es admisible y cuál es la respuesta, y no guarda tabla de respuestas propia
inputes una intención: «adelante», «derecha», «gira la torreta hacia allá» — aceptada tal como llega, porque no afirma nada sobre el mundo
a claimed posees una afirmación: nunca un hecho. Fuera de la tolerancia declarada se recorta a la pose admisible más cercana, y eso produce un pose_clamped observable
input sequencinges obligatoria: el mismo número de secuencia nunca se aplica dos veces, y uno menor se descarta
a limitrecorta, no rechaza: «diez metros hacia adelante en este Tick» se vuelve lo que sea admisible en vez de un error. Eso es lo que hace del límite un anti-cheat por construcción — el servidor físicamente no puede producir la pose ilegal — y es por lo que no se inunda al cliente con rechazos en cada cuadro
an impulse obeys the same constraintsel retroceso, un empujón, una explosión y un knockback llegan desde fuera de la entrada, y ninguno de ellos esquiva Collision: el retroceso no mete un tanque dentro de una roca
identical rules, not identical bitsno se promete resultado idéntico bit a bit entre plataformas. Lo que se promete son las mismas reglas, y reproducibilidad dentro de una autoridad
the stepes puro y va dirigido por el Tick: el mismo código hace avanzar el movimiento en el servidor y dentro del bucle de predicción del cliente, que es lo que hace exacta la reconciliación

Errores

  • Un modelo de movimiento ausente en la Entity, un preset parcialmente llenado, y un impulso sin decaimiento declarado son todos fallos de validación en el deploy, no en runtime — un impulso sin final es un defecto de Declaration, así que nunca llega a un jugador.
  • La generación esperada no coincidió es un fallo de precondición, que vale repetir tras volver a leer: la entrada enviada antes de un respawn no debe aplicarse después de él.
  • La tasa de entrada excedida responde en la categoría de límite de tasa con un plazo.
  • La Entity no es controlable es un conflicto, y repetirlo tiene sentido solo después de que cambie el estado.
  • Tres cosas no son rechazos en ninguna dirección, y las tres son observables. La entrada rancia se descarta, una intención por encima de un límite se recorta, y una pose por encima de la tolerancia se recorta como pose_clamped. Hacer cualquiera de las tres en silencio dejaría al cliente creyendo que se aplicó y divergiendo del servidor para siempre.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Velocidad y aceleración máximas — recortadas, nunca rechazadas.
  • Giro máximo por entrada — recortado.
  • Tasa de entrada por Actor — un límite de tasa con un plazo.
  • Pasos de recuperación — más allá del tope los pasos se descartan con una consecuencia declarada: el tiempo de simulación se atrasa y eso es observable, en vez de recuperarse de un salto que se lee como si todos se teletransportaran a la vez.
  • Magnitud del impulso — recortada al máximo declarado.
  • Impulsos simultáneos por Entity — uno nuevo desaloja al más viejo, y el desalojo es observable; no hay suma silenciosa e ilimitada.
  • El tiempo de vida de una pose afirmada — una más vieja que el periodo declarado no se considera.

Flujo del usuario

El viaje de un knockback: el player conduce, el attacker del otro tanque dispara, y el impulso aterriza como una pose reconciliada en la pantalla de la víctima. La ability y el projectile son entity presets — Declarations sobre Entities, no módulos que montes.

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Prediction & Lag Comp

El jugador apretó saltar hace 50 ms. El paquete recién llegó ahora. No se cayó. Predicción hacia adelante y compensación hacia atrás sobre datos que llevan su tiempo de evento verdadero: el cliente lo siente instantáneo, el servidor sigue teniendo razón, y los impactos se juzgan en la línea de tiempo del tirador.

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • La entrada debe sentirse instantánea bajo latencia mientras el servidor sigue siendo autoritativo — predice hacia adelante, reconcilia ante divergencia.
  • Los impactos deben juzgarse en la línea de tiempo del tirador: ResolveAt rebobina las hitboxes al Tick de vista informado.
  • Los arcos de puntería y las marcas de aterrizaje deben coincidir con los desenlaces — cliente y servidor pronostican la misma Trajectory.
  • Los campos críticos para el juego no deben revertirse nunca — declara qué se predice y qué espera al servidor.
  • El efecto de goma necesita ajuste: ventanas por Entity, tolerancias y telemetría de predicciones erradas.
  • Sáltatelo cuando la latencia no duele — los juegos por turnos o lentos corren bien con Deltas de Data a secas.

Quién hace qué

ActorEn esta página
schema-authordeclara los campos predichos frente a los solo-autoritativos; fija la ventana de predicción
room-ownerresuelve impactos sobre un estado histórico; rebobina el mundo
playerpredice y reconcilia el movimiento; se suscribe a las correcciones

A qué Rooms aplica esto. Este módulo corre donde la plataforma avanza la simulación — Rooms declaradas con Host = "Backend". Si tu propio game server es dueño de la simulación (PlayServ como metaservidor), el movimiento, la colisión y la predicción se quedan del lado del motor, y esta página describe la alternativa alojada en la plataforma, no un requisito.

De un vistazo

La palabra cubre tres cosas distintas, no deben fusionarse, y cada una tiene su propio artículo. Tienen autoridades distintas y modos de fallo distintos — una sola palabra para las tres significa que ajustar una cambia en silencio las otras dos.

MecanismoQué haceCorre enCuándo se equivoca
Predecir tu propio movimientoaplica el modelo declarado a tu propia entrada sin esperar al servidorel clienteuna corrección, reproducida y suavizada
Mostrar a los demás jugadoresdibuja a las demás Entities entre los estados que lleganel clienteun tirón visible
Compensación de lagrebobina los objetivos al momento que vio el tiradorel servidoralguien muere injustamente

Esta página es el eje: el modelo compartido, las Declarations compartidas, y los presets que eligen una combinación por ti. Los tres artículos son donde cada mecanismo se explica de verdad.

Cuatro presets, y «sin predicción» es uno de ellos.

PresetPredice lo tuyoCompensaSuaviza a los demás
shootersíen una ventana de alrededor de segundo y mediosí
arcadesínosí
observernonosí
sin predicciónnonono — el estado llega de la autoridad con una ventana de interpolación declarada

El último no es un esbozo. Los juegos por turnos, las estrategias y la mayoría de los títulos móviles no quieren predicción alguna, y un «no predecimos» declarado le dice al cliente que muestre el estado tal como es en vez de adivinar.

Nada de esto aplica bajo autoridad externa. Los tres mecanismos existen para Rooms que ejecuta nuestra simulación. Cuando el servidor de juego de un estudio o un master-client es dueño del Tick, la predicción es asunto de quien lo ejecuta — consulta Quién ejecuta el Tick.

El módulo no es dueño de ningún modelo de movimiento, de ninguna tabla de reacción, de ninguna geometría ni de ninguna ventana de historial propia. Esas pertenecen a Locomotion, Collision, Map y Entity respectivamente. Prediction las aplica antes o las lee hacia atrás; nunca declara una segunda copia.

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

La resolución compensada por lag responde «dónde estaba cada uno cuando se hizo este disparo»:

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

El pronóstico de trayectoria, compartido por servidor y cliente (arcos de puntería, marcas de aterrizaje). El pronóstico es una operación de este módulo, extrapolada contra el conjunto de obstáculos del mapa, así que ambos lados dibujan el mismo arco a partir de las mismas entradas:

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

El modelo

La predecibilidad se declara sobre un aspecto — y un aspecto cambiado solo por la autoridad, mediante reglas que el cliente no tiene, no puede marcarse predecible: eso es un fallo de validación en el deploy, no una sorpresa en runtime.

Qué se declara.

DeclaraQué es
predictable aspectscuáles puede hacer avanzar el cliente por delante de la autoridad
divergence thresholdpor debajo de él una corrección se suaviza; por encima se acepta el estado de la autoridad tal como llega. Declarado, y managed — no una perilla diaria de diseñador
display mode for remote entitiesinterpolación entre estados llegados, o extrapolación
interpolation delaycuánto se retrasa la representación de los demás, declarado en vez de ajustado a ojo
extrapolation windowmás allá de ella una Entity queda marcada como rancia y la extrapolación cesa
compensation windowhasta dónde atrás puede llegar un rebobinado, y es managed
what is rewoundposiciones y orientaciones de los objetivos, y la geometría de los obstáculos dinámicos si se declara histórica

Qué se rebobina, y qué deliberadamente no.

Qué es
rewoundlas posiciones y orientaciones de los objetivos, y la geometría de los obstáculos dinámicos donde el tipo los declara históricos
not rewoundel estado de vida — los muertos no reviven para que les disparen — y la propiedad, el puntaje y el inventario
the rule behind the splitla decisión se toma en el pasado; el efecto se aplica en el presente
SiempreQué es
authoritative state names the input it sawlleva el número de la última entrada aplicada, que es lo que hace la reconciliación exacta y no aproximada
divergencees observable: el cliente sabe que su predicción fue corregida, en vez de derivar calladamente
a view timees una afirmación, no un hecho: el momento en que el Actor dice que vio. Más allá de la ventana la plataforma rechaza en vez de extrapolar: extrapolar en silencio es un regalo para un tramposo, a quien le bastaría con enviar un tiempo más viejo
a rewind promises no reproducibility over floating pointla misma restricción que en todo el resto del contrato
one history ring, two consumersla reconciliación y las consultas compensadas por lag leen ambas la pista de historial instantáneo de la Entity. Rebobinar pertenece aquí y no a los módulos que se rebobinan: el anillo restaura las poses del Tick en disputa, y a Collision se le hace después su pregunta corriente de superposición sobre esas poses — no guarda historial propio y no sabe nada de un «Tick de vista»
tick del cliente: aplicar entrada localmente (predecir) → almacenar → enviar, sellado por tick
tick del servidor: avanzar el mismo modelo de movimiento → estado autoritativo → Delta hacia afuera
recepción cliente: estado autoritativo para el tick T → si hay divergencia más allá de la tolerancia:
                   rebobinar hasta T → reproducir las entradas almacenadas T+1..ahora → suavizar

Solo los campos declarados como predichos se revierten alguna vez; la deriva mínima se suaviza, la divergencia real rebobina y reproduce.

Errores

  • Un tiempo de vista más allá de la ventana es un conflicto: envía uno actual. Extrapolar en su lugar le entregaría a un tramposo el mecanismo entero.
  • Una petición de historial más allá de la ventana es igualmente un conflicto.
  • Dos defectos de Declaration se atrapan en el deploy: una ventana de compensación más grande que el búfer de historial, y un aspecto marcado predecible cuando el cliente no tiene reglas con las que predecirlo. Ninguno de los dos puede llegar a una partida viva.
  • Dos cosas no son errores, y ambas son observables. Un búfer de entrada lleno suspende la predicción hasta la confirmación en vez de descartar entradas en silencio; y una divergencia más allá del umbral significa que el estado de la autoridad se acepta tal como llega, que es la corrección declarada y no una falla.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • La ventana de compensación — más allá de ella un rechazo, nunca extrapolación.
  • La ventana de extrapolación para los demás — la Entity se marca como rancia y la extrapolación se detiene.
  • El búfer de entradas sin confirmar — la predicción se suspende hasta la confirmación; las entradas nunca se descartan en silencio.
  • La profundidad del historial para un rebobinado — no menor que la ventana de compensación, y eso se comprueba en el deploy.
  • La tasa de acciones que llevan un tiempo de vista — un límite de tasa con un plazo.
  • Entities predichas simultáneamente por cliente — más allá del tope no se predice, y eso es degradación declarada y no un rechazo.

Flujo del usuario

Un disparo hecho bajo latencia, juzgado con justicia en la línea de tiempo del tirador y confirmado en ambas pantallas. El projectile y el bloque de Stats de la víctima son entity presets — Declarations sobre Entities, no módulos que montes.

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

Predecir tu propio movimiento

Esta es la máquina propia del cliente, y es el único de los tres mecanismos de predicción cuyos errores son baratos. Actúas sobre tu propia entrada antes de que el servidor haya respondido, el servidor responde, y donde los dos discrepan tu cliente se corrige a sí mismo. Un error aquí cuesta una pequeña corrección visual — que es exactamente por lo que es seguro ser agresivo con ello.

La predicción es una repetición de las reglas declaradas, no una segunda copia

Tu cliente no ejecuta una implementación paralela de tu movimiento. Ejecuta el mismo modelo declarado que ejecuta la plataforma — el modelo pertenece a Locomotion, y la predicción solo lo aplica antes. Esa es toda la razón de que los dos lados coincidan la mayor parte del tiempo: hay un solo conjunto de reglas, aplicado dos veces.

Lo que significa que no hay una operación «predecir» que llamar ni una operación «corregir» tampoco. La predicción ocurre porque el aspecto se declaró predecible, no porque invocaras algo.

Qué se predice se declara por aspecto

La predecibilidad es una Declaration sobre el aspecto de la Entity, y deliberadamente no es un interruptor global:

  • Un aspecto que el cliente puede computar — la posición bajo tu propia entrada — puede predecirse.
  • Un aspecto que la autoridad cambia mediante reglas que el cliente no tiene no debe predecirse. Si el cliente no puede derivarlo, adivinarlo produce un rollback que el jugador lee como que el juego miente.

Esa línea es donde decides qué tiene permitido parpadear y qué debe estar bien a la primera.

El protocolo de corrección, y los dos números que le dan forma

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

El estado autoritativo llega llevando el número de la última entrada que aplicó, así que tu cliente sabe exactamente cuánto de su propio búfer sigue sin confirmar. A partir de ahí:

  1. Acepta el estado autoritativo.
  2. Reproduce las entradas almacenadas que vinieron después de la que reconoce.
  3. Reconcilia el resultado con lo que ya estabas mostrando.

Dos números declarados deciden cómo se siente eso. El umbral de divergencia: por debajo de él la corrección se suaviza, por encima tu cliente salta y reproduce. Y la cota del búfer de entradas sin confirmar: el desbordamiento no queda indefinido — la degradación está declarada y es observable, así que un cliente con mala conexión sabe que dejó de predecir en vez de derivar calladamente.

La divergencia es observable para el cliente que la tuvo, y solo para ese cliente. Puedes saber que tu predicción fue corregida y por cuánto — útil para ajustar, y para mostrarle al jugador un indicador honesto de conexión. No puedes leer la divergencia de otro: el tamaño de una predicción errada es información sobre su conexión, no sobre el juego. La corrección es del lado del cliente, porque el estado de la autoridad es lo que a todos los demás ya se les estaba mostrando.

Si llegas desde otro lado

  • Mover 2.0 de Unreal. La forma es familiar: entradas selladas por Tick, un modelo de movimiento, correcciones desde la autoridad. La diferencia está en dónde vive el modelo — aquí tú lo declaras y la plataforma lo simula, así que no hay un componente de movimiento nuestro del que heredes o al que reemplaces.
  • Netcode de rollback-and-replay, como en Photon Fusion. Reproducir tus propias entradas sin confirmar tras una corrección es el mismo mecanismo, y está aquí por completo. Lo que deliberadamente no está aquí es volver a ejecutar el mundo a posteriori — consulta Compensación de lag para saber qué pasa en su lugar, y por qué.

Qué no cubre esto

Las Entities de los demás jugadores no se predicen, se muestran — eso es Mostrar a los demás jugadores. Juzgar un disparo en la línea de tiempo del tirador es un mecanismo del servidor y vive en Compensación de lag. Y ninguno de los tres aplica en absoluto cuando el modo de autoridad de la Room es externo: entonces el Tick le pertenece a quien lo ejecuta, y la predicción también.

The live game

Mostrar a los demás jugadores

Nadie predice a los demás jugadores — se los muestra. Recibes su estado a intervalos y tienes que dibujar algo en el medio. Un error aquí no le cuesta la vida a nadie; cuesta un tirón visible, que es por lo que recibe Declarations propias en vez de compartir las de la predicción.

El modo de representación se declara, no se adivina

Para las Entities que no son tuyas, la Room declara cómo llenar el hueco entre los estados que llegan: interpolar entre los estados que tienes, o extrapolar más allá del más nuevo. Eso es una Declaration sobre la Entity, así que la respuesta es la misma en todos los clientes y no se desvía según quién haya implementado el renderizador.

El retraso de interpolación también se declara. Mostrar a los demás jugadores con suavidad significa mostrarlos ligeramente tarde, en una cantidad declarada. Nombrar el número es la gracia: un retraso no enunciado es un reporte de bug que no puedes reproducir, y uno enunciado es una decisión de diseño que puedes ajustar contra tu género.

La extrapolación se detiene en vez de inventar

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

La ventana de extrapolación está declarada, y pasada ella la Entity deja de mostrarse en movimiento en vez de seguir sobre una conjetura. Extrapolar indefinidamente pone al jugador a disparar a un blanco que nunca estuvo ahí, y el jugador no puede darse cuenta — un congelamiento visible es el fallo del que se puede volver.

Por qué esto está separado de predecir el propio

Los tres mecanismos de predicción tienen autoridades distintas y modos de fallo distintos, y una sola palabra para los tres significa que ajustar uno cambia en silencio los otros dos.

MecanismoCorre enCuándo se equivoca
predecir el propioel clienteuna corrección, reproducida y suavizada
mostrar a los demás jugadoresel clienteun tirón visible
compensación de lagel servidoralguien muere injustamente

Que es también por lo que existe un preset observer que lleva este mecanismo y nada más: un espectador no tiene entrada propia que predecir, así que darle ajustes de predicción sería configurar algo que no hace.

The live game

Compensación de lag

Este es el mecanismo del servidor, y el único de los tres cuyos errores matan a alguien. Cuando se equivoca en una decisión, un jugador muere injustamente — y a favor de quien tiene la peor conexión. Todo lo de esta página está moldeado por esa asimetría.

La pregunta que responde es estrecha: ¿qué vio realmente el tirador? Una acción puede llevar un tiempo de vista, el Tick que el Actor estaba mirando cuando actuó, y la plataforma restaura las poses de los objetivos en ese Tick para que el disparo se juzgue contra lo que había en su pantalla.

El tiempo de vista es una afirmación, no un hecho

Llega desde el cliente, así que es una aseveración de quien llama y se la trata como tal. Dos consecuencias:

  • La ventana de compensación está acotada, y fuera de ella la plataforma rechaza. No extrapola por ser servicial. Un rechazo es una decisión que puedes ver; una extrapolación silenciosa es una decisión que no puedes ver.
  • Leer el estado pasado de un objetivo sigue obedeciendo a la visibilidad. Preguntar por un Tick histórico no es una forma de rodear a Visibility — lo que no podías ver entonces, no puedes leerlo ahora.

Y «el impacto no contó» es un veredicto, no un error: una respuesta exitosa con una razón legible por máquina. Tu código hizo una pregunta legítima y recibió un no legítimo.

Qué se revierte está declarado, y no es todo

Revertir todo suena consistente y produce dobles bajas: dos jugadores se disparan mutuamente, a ambos se los rebobina a un momento en que ambos están vivos, ambos aciertan. Revertir nada cancela la compensación de lag misma. La frontera entre las dos es una lista declarada, no la intuición de una implementación.

El rebobinado mismo pertenece aquí y no a los módulos que se rebobinan. El anillo de historial restaura las poses del Tick en disputa y a Collision se le hace después su pregunta corriente de superposición sobre esas poses — la colisión no guarda historial propio y nada en ella sabe qué es un Tick de vista. El anillo mismo es la pista de historial de la Entity, no un segundo almacén.

La decisión se toma sobre el pasado; el efecto se aplica en el presente

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

La compensación de lag responde una pregunta sobre el momento de vista del tirador. Las consecuencias — el daño, la muerte, el premio — se aplican al estado actual. Lo que pasó entre el momento de vista y el momento de la decisión no se cancela y no se recalcula.

Así que esto es observable, y es intencional: un jugador puede alcanzar a disparar después de haber sido matado por el disparo rebobinado de otro. Cancelar eso significaría reproducir el mundo encima de un rebobinado que no promete reproducibilidad — lo que fabrica divergencia en vez de quitarla.

La resimulación del lado del servidor queda fuera de alcance, deliberadamente. Recalcular consecuencias contra una verdad nueva necesita un punto de referencia fijo que el estado en coma flotante no nos da. Lo que queda es todo aquello sobre lo que se apoya el módulo: un cliente reproduciendo sus propias entradas sin confirmar (Predecir tu propio movimiento), y la compensación de lag como leer el pasado para una decisión. Así funciona en la práctica el favorecer al tirador.

Si llegas desde otro lado

  • La compensación de lag que favorece al tirador, tal como se publica en la mayoría de los shooters competitivos: el mismo mecanismo, y esta página es eso.
  • Netcode de rollback completo. El rebobinado está aquí; la reproducción del mundo posterior no, y el párrafo de arriba es por qué. Si tu diseño depende de que las consecuencias se recalculen a posteriori, esa dependencia es lo que hay que plantearnos temprano en vez de descubrirlo tarde.

También vale saber

  • La implementación es sobrescribible. Si tu juego necesita una regla de compensación distinta, puedes reemplazar la nuestra, y el reemplazo declara cuáles de las Declarations honra.
  • No hay puntos de extensión en la ruta de predicción y corrección. Esos corren a la tasa del Tick, y un Hook en ese bucle sería un Hook que no puedes permitirte.
  • Nada de esto aplica bajo autoridad externa. La compensación de lag existe para Rooms que ejecuta nuestra simulación. Cuando el Tick le pertenece al servidor de juego de un estudio o a un master-client, la compensación le pertenece a quien lo ejecuta — consulta Quién ejecuta el Tick.
The live game

Bots

Un bot entra como un jugador corriente. Solo el cerebro vive en otra parte. Misma sesión, misma validación de entrada, mismas reglas, misma ACL. La Room no puede notar la diferencia, por diseño, así que los bots ejercitan las reglas reales de tu juego y el anti-cheat nunca necesita una excepción para bots.

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

Cuándo usarlo

  • Tus lobbies necesitan llenarse en horas de poca gente — FillRoom completa las partidas hasta una cuota y los bots ceden asientos a medida que llegan humanos.
  • Los bots deben jugar con las reglas reales — validación de entrada, ACL, Visibility — para que el anti-cheat nunca necesite una excepción para bots.
  • Traes un cerebro externo — una política aprendida, un servicio — que entra por ConnectAsBot como cualquier jugador.
  • La Entity de un jugador desconectado debe traspasarse a un bot y volver al reconectar, sin que el asiento ni Prediction lo noten.
  • Sáltatelo cuando el personaje nunca decide — un NPC de diálogo sin cerebro vive en World Objects.

Esta página es la mitad de la conexión. Meter un bot en una Room, llenar un lobby hasta la cuota, traspasar un asiento entre un bot y un humano. Escribir la cosa que decide es la otra mitad — Cómo escribir un cerebro, que especifica el zócalo en el que se enchufa un cerebro.

Quién hace qué

ActorEn esta página
bot-brainse conecta como jugador; recibe percepción; envía comandos
room-ownerdeclara perfiles, llena Rooms hasta la cuota, traspasa bot/humano

De un vistazo

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

Un cerebro externo (IA más pesada, una política aprendida, un servicio) se conecta como cualquier jugador:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

El modelo

Un bot no introduce ninguna noción propia — ni un participante, ni un canal de entrada, ni una zona de visibilidad, ni comportamiento. Es una credencial de Actor vistiendo lo que Rooms, Data y Locomotion ya declaran.

Qué lleva la Declaration de un bot.

DeclaraQué es
thinking tickcon qué frecuencia se les pregunta a los cerebros, y no es el Tick de simulación: los cerebros corren afuera, y una llamada de red por Tick es inviable
direction of the brainsdónde se ejecutan — una función en la nube, el backend del estudio, su servidor de juego. Cuál de ellos no es parte del contrato, y moverse entre ellos no es un cambio rompedor
actor presetlos derechos del bot, como un preset de Actor corriente
visibility of the bot markersi se les avisa a los participantes. La marca en sí siempre existe y la plataforma siempre la observa; si los jugadores la ven es Declaration del tipo de Room, porque en algunos mercados revelar un oponente de IA es una obligación y en otros es una decisión de producto
behaviour when the brains are unavailableuno de tres, sin valor por defecto: do nothing · leave the room · fall back to built-in default behaviour
roster fillingdeclarado por el tipo de Room — cuántos, bajo qué condición, hasta qué momento. Matchmaking no sabe nada de bots: empareja actores, y no decide con quién completar

Qué vale para todo bot.

SiempreQué es
an actor, not a playerlleva una credencial de Actor pero no tiene proveedor de login, ni vínculos, ni sesiones
economic ownershipno tiene ninguna — ni derechos, ni compras, ni entradas de leaderboard — de lo contrario los bots terminan en la clasificación y en la economía
perceptiones la de un jugador: la misma zona de visibilidad, el mismo predicado, el mismo límite de cantidad de objetos. Un bot y un jugador en la misma posición reciben el mismo conjunto de objetos, así que un bot no puede ver a través de los muros más de lo que puede un jugador
wider perceptiones un preset de Actor, no una propiedad del bot: un modo de depuración o de «entrenador omnisciente» se declara como un preset con un predicado más ancho
between thoughtsrige el último comando, y su destino es lo que el tipo de movimiento ya declara para la entrada rancia — un bot cuyo cerebro está pensando es el mismo caso que un jugador al que se le cayó la red
room capacitycuenta a un bot: ocupa un asiento como cualquiera

Errores

  • La Room no acepta bots es un conflicto, y repetirlo no va a ayudar.
  • El límite de bots está agotado es un conflicto, no un forbidden — el permiso de introducir uno se tiene; la Room está llena. Vale repetir en cuanto la Room se libere.
  • Un comando para un bot desde un Actor sin el permiso responde forbidden, y repetir no sirve de nada.
  • Que los cerebros no estén disponibles no es un error — es uno de los tres comportamientos declarados de arriba. Si estaban lentos, caídos o pensando es asunto de la dirección que los ejecuta, y no es parte del contrato; lo observable es lo que es observable para cualquier participante.
  • Declarado en el deploy, rechazado en el deploy: un bot nombrado como dueño de una entrada de leaderboard, y un Tick de pensamiento ausente, son ambos fallos de validación en tiempo de deploy y no sorpresas en una Room viva.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Bots en una Room — introducir uno se rechaza como conflicto; los bots existentes nunca se quitan para hacer sitio.
  • Bots por Project — el mismo conflicto.
  • El Tick de pensamiento por abajo — una Declaration más rápida que el piso se rechaza en tiempo de deploy, porque una llamada de red por Tick es inviable.
  • La tasa de comandos para un bot — un límite de tasa con un plazo.
  • El plazo para una respuesta de los cerebros — una vez vencido, aplica el comportamiento de indisponibilidad declarado.

Flujo del usuario

Un host completa el lobby hasta la cuota, un cerebro externo toma uno de los asientos, y la Room corre con las reglas reales de principio a fin.

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

Cómo escribir un cerebro

Un cerebro es código corriente que responde una pregunta: qué hace este bot a continuación. Corre donde tú quieras — una función en la nube, tu propio servicio, un cliente sin interfaz — y le habla a la Room por la misma superficie que usa el cliente de un jugador humano. Esta página especifica el zócalo en el que se enchufa: qué recibe un cerebro, qué puede mandar de vuelta, y cuándo. Conectar un bot cubre la otra mitad: meter un bot en una Room.

Qué está zanjado, y contra qué puedes construir hoy

La plataforma no publica IA de juego. Nada de árboles de comportamiento, nada de sistema de utilidad, nada de cerebro de navegación. Eso no es un hueco esperando a llenarse — es la frontera. Las decisiones son tuyas, y el trabajo del módulo es hacer tus decisiones indistinguibles de las de un jugador.

Un cerebro no es un Hook. Un Hook envuelve un paso nuestro. Un cerebro no es un paso nuestro en absoluto: corre fuera de la Room, en su propio horario, y a la plataforma no le importa desde qué dirección se abrió la conexión. Por eso un cerebro puede ser una función en la nube, un servicio que tú hospedas, o un cliente sin interfaz — y por eso ninguno de ellos es más nativo que los otros.

El zócalo es percepción hacia adentro, comandos hacia afuera, y ambos lados son deliberadamente los del jugador:

Qué es
percepciónexactamente lo que recibiría un jugador en ese asiento — los mismos Deltas, a través de las mismas reglas de Visibility. Un bot no puede ver a través de los muros más de lo que puede un jugador.
comandosexactamente lo que enviaría un jugador en ese asiento. No existe ningún canal de entrada privilegiado.

Si un juego genuinamente necesita un bot que vea más — un modo de depuración, un modo de entrenamiento — eso es un ensanchamiento declarado, no un efecto secundario de ser un bot.

El Tick de pensamiento está declarado, y no es el Tick de simulación. Los cerebros están afuera, así que piensan a su propia cadencia. Entre dos pensamientos rige el último comando — que es la cosa más importante que hay que tener en cuenta al diseñar, porque significa que un cerebro que piensa lento no produce un bot que se queda quieto, produce un bot que sigue haciendo lo último que decidió.

Que los cerebros se vayan tiene comportamiento declarado, y no hay valor por defecto. Tú dices qué pasa cuando el cerebro deja de responder, por tipo de Room. «Cerebros no disponibles» es un Event retenido, así que un suscriptor tardío se entera de la situación actual y no solo de los cambios futuros.

Qué deliberadamente no puede ser un bot

Vale leerlo antes de diseñar alrededor de ello, porque son rechazos y no omisiones.

  • Un bot no es un jugador, y no es dueño de entitlements, compras ni registros de leaderboard. Un bot que pudiera tenerlos sería una forma de fabricarlos.
  • La marca de bot siempre existe y siempre es observable para la plataforma. Si tu juego se la muestra a los jugadores es decisión tuya; que exista no lo es.
  • El módulo no guarda historial de qué decidió un bot ni por qué. Eso es asunto tuyo, en tu telemetría — no vamos a convertirnos en el sitio donde se almacena el razonamiento de tu IA.
Services around the game

Auth & Players

El sign-in es un paso sobrescribible, no una caja negra. Proveedores, sesiones, vinculación de identidades, baneos. Cada punto del flujo — antes y después del sign-in, antes y después de un vínculo, antes y después de una fusión, ante un cambio de estado — es un punto de extensión declarado con un tipo declarado: una compuerta que puede rechazar el paso, o un observador que no puede.

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Los jugadores deben iniciar sesión — dispositivo, correo, Apple, Google, Steam o propio — con crear-al-primer-sign-in como una bandera, no como un segundo flujo.
  • Una cuenta de invitado debe poder ascender después — Link agrega Steam con el progreso intacto, y las fusiones reconcilian dos cuentas en un jugador.
  • La política debe correr donde no se pueda saltar — una compuerta de región antes del sign-in, un pack de bienvenida después del sign-in que creó al jugador.
  • La moderación necesita dientes — revocar sesiones, suspender, banear dispositivos, con un Event banned que todos los sistemas vivos oyen a la vez.
  • El contexto declarado (región, plataforma, build) debe llegar a cada Hook posterior sin que cada uno vuelva a leer al jugador para enterarse.
  • No hay nada más liviano a lo que saltar — todos los demás módulos nombran a quien los llama a través de este, y auth no puede apagarse mientras cualquiera de ellos necesite un Actor jugador: el configurador de módulos lo rechaza, y nombra a los dependientes.

Quién hace qué

ActorEn esta página
playerinicia sesión, vincula o desvincula identidades, refresca, cierra sesión
moderatorrevoca sesiones; banea, suspende o restaura jugadores
backend-servicecontrola el sign-in por región; siembra las primeras filas de un jugador nuevo; lee y revoca sesiones

De un vistazo

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

Cada punto se personaliza donde se declara; las formas que puede tomar un manejador están reunidas en 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.

La forma declarada es lo que dibuja el panel: cada punto muestra sus manejadores, su tipo y el orden resuelto. El tipo es la parte con dientes:

TipoCuando el manejador mismo fallaAl rechazar
una compuertael paso se rechaza — un control de región inalcanzable no es un control de región aprobadoun código del catálogo de la plataforma más una razón humana. Quien llama ramifica por el código; el texto de la razón es libre de cambiar y de traducirse
un observadorel paso queda hecho, así que un pack de bienvenida que no aterrizó cuesta un cofre, no el sign-inno puede rechazar

Lo que ningún manejador puede hacer es decidir quién inició sesión. Una compuerta responde sí o no sobre una identidad que la plataforma ya estableció; no nombra al jugador, no reparte una identidad, ni sustituye a la confirmación del proveedor. Esa línea es la diferencia entre un sign-in sobrescribible y un sign-in salteable.

El modelo

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

Un jugador es un portador de identidad, no una fila de tu schema, y su identificador es estable y nunca se reutiliza — ni siquiera en una fusión: el id de un jugador fusionado sigue resolviendo en vez de volverse una referencia colgante. Un vínculo es una tripleta: proveedor · sujeto externo · jugador.

SiempreQué es
provider + subjectes único, y esa unicidad es fuente de un conflicto, no de una prohibición — la respuesta a «esta cuenta ya está tomada» es elegir una fusión, no que te digan que no
at most one link per provider per playeruna segunda cuenta del mismo proveedor es un conflicto
an external subjectnunca es el identificador de un jugador: le pertenece al proveedor, y usarlo como el nuestro ataría nuestros ids a los suyos
identity kind and access statusson ejes distintos: anonymous frente a registered es uno; active / suspended / banned es otro. Confundirlos vuelve inexpresable «anónimo baneado» o «registrado suspendido»
a session and a credentialson cosas distintas: una sesión es el registro; una credencial es lo que presentas. Revocar una sesión invalida todas sus credenciales y cierra sus suscripciones abiertas
many simultaneous sessionscada una revocada de forma independiente
a credential's claimsson contexto declarado — región, configuración regional — y solo contexto. Una afirmación nunca lleva autoridad
a device fingerprintno es una identidad: nunca es fundamento para admitir, solo fundamento para rechazar, y se almacena y se compara en forma irreversible

Las tres máquinas.

DeEstados
la clase de identidadanonymous → registered, y la transición es de una sola vía
el estado de accesoactive ⇄ suspended, y active → banned → active para un desbaneo
el jugadoralive → merged, donde merged es terminal: un jugador fusionado no vuelve a iniciar sesión

Qué declara el consumidor.

DeclaraQué es
sign-in policysi se permite el sign-in anónimo, y el resto de las reglas alrededor de iniciar sesión. Declarada como un atributo sobre el punto de montaje del módulo — no un archivo de configuración al lado del código, y no construida en runtime
default roleel conjunto que lleva un jugador nuevo en su primer sign-in. No hay valor por defecto para el valor por defecto: no declares nada y los jugadores nuevos llegan sin roles, que es una Declaration legítima y no una omisión
session policyqué pasa cuando se alcanza el techo de sesiones simultáneas — desalojar la más vieja con un Event, o rechazar la nueva. Sin valor por defecto
deletion policycómo el borrado de un jugador alcanza a los datos que lo referencian

Dónde se configura un proveedor. En el plano del operador, no en código — una credencial de tienda no va en un repositorio. Lo que se declara llega a la consola administrativa para lectura.

Qué hace otorgar un rol. Los roles no son solo asunto de un operador: la superficie lleva otorgar y revocar para un jugador, así que un juego puede ascender a un oficial de gremio o entregarle sus poderes al anfitrión de un torneo desde su propio código.

SiempreQué es
it is not self-promotionotorgar exige el átomo de permiso declarado para ello, y un Actor sin ese átomo recibe un forbidden a secas y no una operación nula en silencio
granting is idempotentotorgar un rol que el jugador ya ostenta es un éxito, no un conflicto: el estado es el conjunto de roles, no el historial de llamadas, así que a diferencia del sign-in esta operación no necesita clave de idempotencia
revoking is not instanty no fingimos que lo sea. Surte efecto sin volver a emitir la credencial, y es observable a más tardar en el límite de obsolescencia declarado de la caché de derechos — así que el código que otorga un rol e inmediatamente lo comprueba en un cliente conectado tiene que diseñar alrededor de esa ventana
the default rolese declara por Project: el conjunto que lleva un jugador nuevo en su primer sign-in. No hay valor por defecto para el valor por defecto — no declares nada y los jugadores nuevos llegan sin ningún rol, que es una Declaration legítima y no una omisión

De qué están hechos los roles y qué desbloquean es Access & Roles.

Errores

  • Sin credencial, o con una vencida, responde not authenticated y un refresco lo arregla. Una credencial revocada responde igual pero un refresco no la arregla: solo un sign-in nuevo.
  • Una credencial rotada presentada de nuevo es un conflicto — eso es lo que hace detectable la rotación en vez de tolerada en silencio.
  • Un jugador baneado o suspendido, y una huella baneada, responden forbidden, y repetir no sirve de nada.
  • El par proveedor+sujeto está tomado es un conflicto, repetible tras elegir una fusión; una segunda cuenta del mismo proveedor es un conflicto que un reintento no cambiará.
  • Desvincular el último método de sign-in es un rechazo de validación: dejaría una cuenta a la que nadie puede llegar.
  • Fusionar un jugador ya fusionado es un conflicto — merged es terminal.
  • Que el proveedor no esté disponible responde unavailable y vale reintentar con espera creciente; que el proveedor rechace la credencial responde not authenticated y vale un reintento, no un bucle. Colapsar los dos haría que los clientes machaquen a un proveedor que ya dijo que no.
  • La tasa de intentos de sign-in excedida responde en la categoría de límite de tasa con un plazo.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Sesiones simultáneas por jugador — según la política declarada: desalojo de la más vieja con un Event, o rechazo de la nueva. No hay valor por defecto.
  • Intentos de sign-in por periodo, e intentos de vincular un par tomado — un límite de tasa con un plazo, y el contador de intentos queda en el historial.
  • Vínculos por jugador — vincular otro proveedor se rechaza como conflicto.
  • El tiempo de vida de una credencial — not authenticated, repetible con un refresco. El tiempo de vida de una credencial de refresco — solo un sign-in nuevo.
  • Retención de un jugador anónimo sin sign-ins — borrado según la política declarada, con un Event. La política se declara explícitamente; no hay valor por defecto.
  • Entradas en la lista de huellas baneadas — una adición se rechaza, y las entradas viejas nunca se desalojan en silencio.

Flujo del usuario

Una cuenta de invitado en el primer arranque, ascendida a Steam después con el progreso intacto.

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Profile

Un perfil es una vista, y la plataforma casi no es dueña de nada de él. Lo que la plataforma guarda sobre un jugador es el player_id y el perfil de sistema que hay detrás — identidades, sesiones, vínculos con proveedores, todo eso en Auth. Todo lo que un jugador tiene es tu propia Entity, cuyo dueño es ese jugador. Un perfil es el conjunto de esas Entities que tu Project declara, leído para un dueño en una sola pasada.

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Una pantalla necesita la porción de un jugador en una sola llamada — el conjunto declarado se reparte por sus Entities con dueño en vez de que el cliente cosa varias consultas.
  • Las superficies de la plataforma deben mostrar una persona, no un identificador — un leaderboard, una cola de moderación y un ticket de soporte llevan un player_id y nada más hasta que el Project nombra el registro que representa a un jugador.
  • Otro jugador necesita una ficha — la misma lectura contra otro dueño, estrechada por el predicado de fila y la máscara de columnas ya declarados en Access.
  • Un HUD debe seguir en vivo el estado con dueño — la lectura es una selección, y una selección se suscribe.
  • Sáltatelo cuando los datos no son de un jugador — las filas compartidas y globales son una selección corriente de Entity, sin dueño desde el que repartirse.

Quién hace qué

ActorEn esta página
schema-authormarca Entities como propiedad de un jugador y declara cuáles de ellas forman el perfil
playerlee su propio perfil; las escrituras van a las Entities mismas
room-visitorlee el perfil de otro jugador, hasta donde el predicado y la máscara de ese jugador lo permitan

De un vistazo

La pertenencia se declara por Entity, no por campo. La Entity dice que pertenece al perfil; qué puede ver otro jugador de ella es la máscara de columnas sobre el rol que la lee (Access). Un atributo de vista a nivel de campo sería una segunda respuesta a la pregunta que el acceso ya responde, y las dos se desviarían la primera vez que alguien editara una de ellas.

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

La lectura es una selección acotada al dueño — la superficie de consulta de Entity con el dueño fijado y la lista de Entities tomada de la Declaration. Profile es el nombre de esa lectura, no un módulo parado detrás: los mismos derechos, los mismos predicados, los mismos filtros, la misma suscripción, porque es la misma operación.

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

El modelo

ConceptoQué es
player_idtoda la idea que la plataforma tiene de un jugador, más el perfil de sistema que hay detrás — Auth
conjunto del perfillas Entities con dueño jugador que el Project declara como su perfil; declararlo es opcional
selección por dueñola lectura: entra un dueño, salen sus filas a lo largo del conjunto — los mismos derechos, predicados, filtros y suscripción que cualquier selección de Entity
lectura públicaesa selección contra otro dueño, estrechada por el predicado de fila y la máscara de columnas del rol que lee (Access)

Qué vale para toda lectura de perfil.

SiempreQué es
there is no profile recordno tiene identificador propio, ni Revision, ni historial, ni ciclo de vida, porque es una vista sobre filas que sí tienen las cuatro cosas
writes go where the data livesparchea la fila progress, y toda lectura de perfil que la incluya ve el valor nuevo en su siguiente pasada
there is no public writeuna vista no tiene adónde escribir, y el estado compartido y escribible pasa por código de servidor
declaring the set is optionaly no declararlo no es lo mismo que declarar uno vacío: un Project sin conjunto de perfil no tiene lectura de perfil en absoluto y la llamada se rechaza como no disponible, mientras que un resultado vacío diría que el jugador tiene un perfil y da la casualidad de que está en blanco
ownership is a predicateno una columna que la plataforma agrega (Access): owner == caller.player es una instancia del mecanismo, «uno de los participantes» es otra
a derived field belongs to a hookun observador posterior al cambio es lo que sella Progress.Title cuando Level cruza un umbral. Sigue a la escritura; no puede rechazarla

Errores

  • Un Project sin conjunto de perfil declarado no tiene lectura de perfil, y la llamada se rechaza como unavailable — no se responde con un resultado vacío, que diría que el jugador tiene un perfil y da la casualidad de que está en blanco.
  • Una fila que un predicado esconde responde not found, y lo mismo una que no existe: una lectura pública nunca se vuelve una forma de averiguar qué existe pero no es visible.
  • Un campo fuera de la máscara del rol que lee está ausente de la respuesta, no presente y vacío.
  • No hay escritura pública. Una vista no tiene adónde escribir, así que el estado compartido y escribible pasa por código de servidor y no por esta superficie.
  • Una escritura en nombre de un jugador que no nombra al jugador es un rechazo de validación.
  • Declarar el conjunto de Profile es un acto de esquema — fn o adm. Una clave de jugador que lo intente recibe forbidden, y el push se rechaza entero en lugar de declararse a medias.

Límites

Cada techo nombra su comportamiento en la frontera; los números llegan con el capítulo de límites de la plataforma.

  • El tamaño de página de la selección por dueño — recortado al tope con la marca de «hay más» todavía verdadera; devolver menos sin la marca está prohibido.
  • El tamaño de un conjunto incluido por fila — recortado por la misma regla, con la marca sobre la inclusión.
  • La tasa de cambios sobre una instancia — un rechazo por límite de tasa con un plazo; la lectura de perfil es una selección como cualquier otra y hereda los techos de Entity en vez de declarar los suyos.

Flujo del usuario

Del lobby dibujándose al nivel subiendo: una escritura del lado del servidor llega a una pantalla suscrita sin que la pantalla vuelva a preguntar.

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Social

Un concepto nuevo, y todo lo demás está construido con lo que ya tienes. Una relación de dos actores, con un estado propio y un iniciador — eso es todo lo que agrega este módulo. Un clan, un gremio o un escuadrón es un Group con una capa de relación encima, no una segunda clase de cosa; y el bloqueo, que varios módulos necesitan, vive aquí para que un solo lugar sea su dueño.

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Los jugadores se necesitan unos a otros por nombre — amigos, seguidores, listas de bloqueo.
  • Un clan o gremio necesita una puerta — una invitación del Group, una solicitud de entrada de un Actor, y una decisión sobre cualquiera de las dos.
  • Una lista de amigos tiene que mostrar quién está en línea — la presencia se deriva de las sesiones, y quién puede verla es un predicado que declaras.
  • Otro módulo necesita saber que alguien está bloqueado — lee ese estado de aquí en vez de guardar el suyo.
  • Sáltatelo cuando la cosa es un conjunto de actores en vez de un par con un estado: eso es un Group, y un Group por par significaría millones de Groups de dos personas, cada uno con su propio ciclo de vida y sus reglas de entrada.

Quién hace qué

ActorPuedeNo puede
playerproponer una relación o seguir; aceptar, declinar o retirar; romper una mutua; bloquear y desbloquear; leer sus propias relaciones y la presencia de actores relacionados; suscribirse a los cambios; enviar una solicitud de entradaleer la lista de relaciones de ningún otro, bajo relación de participante alguna
moderatordecidir sobre invitaciones y solicitudes de entrada allí donde ostente el átomo de administración de membresíadecidir sobre una intención para la que no tiene permiso — eso responde forbidden

El modelo

Qué lleva una Declaration de relación.

DeclaraQué es
kindsimétrica — el par necesita que ambos lados estén de acuerdo, y la máquina de estados de abajo trata de eso; o de un solo lado — seguir, cuyo único estado es active. La unicidad por par y la idempotencia de una propuesta valen para las dos
re-invitation ruletras un rechazo: prohibida · permitida tras un periodo declarado · permitida de inmediato. Declarada, porque «volver a pedir» es una decisión de producto
presence visibilityun predicado — a todos · solo a los mutuamente conectados · a nadie. No hay valor por defecto
joining mode (sobre el tipo de Group)abierto · por solicitud con una decisión · solo por invitación
retention of declined and brokentras el periodo declarado la relación se elimina, y volver a invitar se vuelve posible sin importar la regla de reinvitación

Los estados de una relación simétrica.

EstadoSignificado
proposedel iniciador propuso y el otro lado no ha respondido
mutualambos lados están de acuerdo
declinedel otro lado rechazó. La relación se conserva, porque la regla de reinvitación necesita saberlo
brokenun lado dejó una relación mutua
blockedun lado bloqueó al otro

Qué vale para toda relación.

SiempreQué es
one entity per pairno dos registros espejados. «A le propuso a B» y «a B le propuso A» son un solo hecho leído desde dos lados
an initiatorestá declarado: quién propuso, cosa que necesitan tanto la representación como la regla de reinvitación
blocked dominatesde ahí no hay transición a proposed ni a mutual
a blockes asimétrico en el control, simétrico en el efecto: solo quien lo puso puede levantarlo, y actúa en ambas direcciones
a refusal on a blockno lo revela: la operación responde not found, así que un Actor bloqueado no puede descubrir el bloqueo sondeando
the block statees propiedad de aquí y se consume en otra parte: Messaging y otros lo leen; ninguno lo muta, y ninguno guarda copia
presencese deriva de las sesiones: nadie la escribe, y el predicado de visibilidad se aplica por solicitante y no una vez por Actor
a deferred intentno ocupa un asiento: una invitación o una solicitud de entrada nunca cuenta para la capacidad del Group — de lo contrario cien solicitudes agotan un clan de cincuenta y nadie puede entrar
a groupconserva un administrador: al menos un Actor debe ostentar el átomo de administración de membresía, y el último no puede simplemente irse: un clan cuyo último administrador se fue nunca podría volver a admitir a nadie
no intra-group roles«oficial de clan» es un Actor que ostenta un átomo, no un rango guardado en una lista
an import never overwriteslas relaciones traídas de un proveedor de login son aditivas: alguien bloqueado no se vuelve amigo porque lo diga un proveedor

Errores

  • Ya es mutua es un conflicto; no hay nada que proponer.
  • Una propuesta a uno mismo es un rechazo de validación.
  • Uno de los lados ha bloqueado responde not found — no forbidden, porque un rechazo que los distinguiera revelaría el bloqueo. Repetir no sirve de nada.
  • Una reinvitación antes del plazo es un conflicto, que vale repetir después de él.
  • Un límite agotado — relaciones, intenciones — es un conflicto, no un forbidden: el permiso se tiene, el sitio no está. Reintenta en cuanto se libere uno, o en cuanto se hayan decidido las intenciones existentes.
  • Una intención vencida es un conflicto: crea una nueva en vez de reintentar la vieja.
  • La salida del último administrador de un Group es un conflicto hasta que el permiso se haya traspasado.
  • Decidir sobre la intención de otro sin el permiso responde forbidden, y repetir no sirve de nada.
  • Una importación desde un proveedor que no está conectado responde unavailable — reintenta con espera creciente.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Relaciones mutuas por Actor — una propuesta se rechaza como conflicto; las existentes nunca se rompen para hacer sitio.
  • Relaciones de un solo lado por Actor — una nueva se rechaza; las existentes se quedan.
  • Propuestas salientes — una nueva se rechaza, y no hay desalojo: una invitación desalojada sería indistinguible de una declinada.
  • Bloqueos por Actor — agregar uno se rechaza como conflicto, y los bloqueos viejos no se desalojan; alguien desbloqueado en silencio empieza a escribir otra vez y nadie sabe por qué.
  • El tiempo de vida de una intención — expired, con un Event.
  • La tasa de propuestas por Actor — un límite de tasa con un plazo.
  • La tasa de cambios de presencia en el Stream — acotada por la tasa de actualización y no descartando cambios.
  • Retención de las relaciones declinadas y rotas — eliminación según el periodo declarado.

Flujo del usuario

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Messaging

Rooms, Groups, jugadores: un solo modelo de direccionamiento para el chat y las notificaciones. Los mensajes llegan a una conversación; las conversaciones son Channels con historial, moderación y entrega fuera de banda encima.

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Los jugadores hablan — chat de Room, canales de gremio, mensajes directos — sobre el direccionamiento que ya tienes: Room, Group, jugador.
  • Los jugadores desconectados igual deben enterarse — notificaciones con plantilla y programables entregan fuera de banda por push.
  • La moderación debe correr antes de la entrega — un Hook previo al envío filtra o rechaza, y el silenciamiento y el bloqueo los aplica la plataforma en todas partes.
  • Los jugadores que vuelven necesitan ponerse al día — History(take: 50) pagina la conversación en el siguiente arranque.
  • Sáltatelo cuando el payload es estado de juego y no conversación — los campos sincronizados de Data y los Channels de Core ya lo reparten.

Quién hace qué

ActorEn esta página
playerenvía y recibe mensajes; lee el historial; silencia o bloquea
moderatorfiltra, censura y prohíbe términos
backend-serviceenvía o programa notificaciones con plantilla

De un vistazo

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

Un mensaje estructurado es un Event declarado, y la conversación lo lleva después por nombre — sin clase de payload que construir en el sitio de la llamada:

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

El mensaje declarado llega tipado en la misma suscripción, así que un cliente que conoce RallyCall recibe campos y no un amasijo.

Las notificaciones son fuera de banda, con plantilla y programables — y se envían desde autoridad fn o adm, nunca desde una sesión de jugador:

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

La moderación como Hooks, el mismo contrato que en todas partes:

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

El modelo

Qué declara un tipo de conversación.

DeclaraQué es
the groupsu listado — un participante es un Actor, exactamente como en Groups
binding to a lifetimeopcionalmente el de otra Entity, para que el chat de una Room desaparezca con su Room

Por dónde pasa la línea entre el sobre y el payload.

ParteDe quién es
envelopede la plataforma: el autor, la conversación, el momento según el reloj declarado
payloaddel estudio, declarado como un tipo de mensaje con campos tipados, y que aparece como superficie de envío propia y no como una bolsa sin tipos

Tres cosas de las que este módulo es dueño y un Event a secas no — el orden dentro de una conversación, el periodo de retención, y el sujeto de la moderación. Por eso el chat no es «un Event con historial»: el orden, el historial y la moderación de un jugador son asunto de la plataforma aquí y no están en Events.

Las tres máquinas.

DeEstados
una conversacióncreated → active → closed
un mensajesent → published | rejected by the filter, y luego editado o borrado, de forma observable
una notificacióncreated → queued → delivered | expired

Qué vale para todo mensaje.

SiempreQué es
order within a conversationes estable y declarado. El orden entre conversaciones no está prometido
editing and deletingson observables: un mensaje nunca se desvanece en silencio — de lo contrario el historial de un cliente y el del servidor divergen sin que nadie lo sepa
historyson los mensajes mismos: con un periodo de retención declarado, leídos por páginas con cursor desde una posición
retention outlives the complaint windowel periodo no es más corto que el tiempo permitido para atender una queja: una queja llega después del mensaje, y un mensaje que ya no existe no deja nada que atender
read statees una posición, no una bandera: una posición por Actor por conversación, y marcar como leído es monótono — la posición nunca decrece, así que una llamada repetida no puede deshacer el avance. El conteo de no leídos es una derivada de esa posición y no un contador propio
sendinges idempotente por clave: dos llamadas son dos líneas de diálogo, así que la clave es lo que hace seguro un reintento
blockinges un predicado de entrega, no una negativa a enviar: no se le avisa a quien envía, porque un rechazo revelaría el bloqueo. El estado mismo vive en Social
the sender composes the payloadla plataforma no lee los datos del destinatario para completar tu texto. La configuración regional del destinatario puede ser una afirmación de contexto declarada que viaja al punto de extensión, así que la sustitución y la traducción son trabajo del Hook — el único sitio que conoce tanto al destinatario como su configuración regional
the delivery routeno es parte del contrato: push, dentro de la app, u otra cosa es una decisión de enrutamiento, no una promesa
deliveryes observable dentro de cotas declaradas: «encolado» siempre; cualquier cosa más allá de eso hasta donde la ruta pueda informar

Cada punto de extensión nombra el tipo que le entrega al Hook: el mensaje saliente antes de la publicación, el mensaje publicado después. Un filtro puede corregir el contenido que se le entregó — enmascarar una palabra es una corrección — pero nunca a quien envía ni la conversación.

Errores

  • Una conversación que no existe o está escondida, y un Actor que no es participante, responden ambos not found — así que un rechazo nunca revela una conversación en la que no estás.
  • Una conversación cerrada es un conflicto.
  • Sin permiso para escribir en este tipo responde forbidden, y repetir no sirve de nada.
  • Que el filtro repruebe el contenido es un veredicto, no un rechazo. La llamada se realizó, el contenido se consideró, la decisión es negativa y la razón es un valor declarado — que es por lo que es distinguible de un rechazo por permisos, y por lo que qué hacer después depende de la razón.
  • Que el filtro no esté disponible responde unavailable y vale reintentar con espera creciente — pero no se publicó nada mientras tanto.
  • Un tipo de mensaje no declarado para esta conversación, y un mensaje sobredimensionado, son rechazos de validación; el contenido nunca se trunca en silencio.
  • La tasa de envío excedida responde en la categoría de límite de tasa con un plazo.
  • Editar el mensaje de otro responde forbidden.
  • Una notificación pasada de su vencimiento es un conflicto: envía una nueva.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • El tamaño del mensaje, y los adjuntos con su tamaño — el envío se rechaza como fallo de validación, nunca se trunca. Los archivos mismos son de Files & UGC.
  • La tasa de envío por Actor — un límite de tasa con un plazo.
  • La profundidad del historial — pasado el periodo un mensaje es desalojado de la retención con un Event, en vez de desaparecer calladamente.
  • Conversaciones por Actor — entrar a otra se rechaza como conflicto.
  • Notificaciones encoladas por Actor — una nueva se rechaza, y el desalojo está prohibido: una notificación descartada en silencio es indistinguible de una que nunca se envió.
  • El vencimiento de una notificación — expired, con un Event.
  • Participantes en una conversación es límite de Groups, y bloqueos por Actor es de Social — ninguno de los dos se repite aquí.

Flujo del usuario

Un mensaje de reagrupamiento llega al gremio entero. Dos roles se reparten la entrega: el online-member, que está en la conversación cuando aterriza, y el offline-member, que recibe un push y lee la convocatoria del historial en el siguiente arranque.

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Catalog & Commerce

Ítems, precios, billeteras, storefronts, compras, entitlements. Integraciones de tienda reales donde las plataformas las permiten (Stripe, App Store, Google Play, Steam, Xbox); storefronts programados y dirigidos por audiencia; y un flujo de compra en el que cada paso es enganchable.

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Vendes cosas — por dinero real a través de Stripe, App Store, Google Play, Steam o Xbox, o por moneda de billetera.
  • Los storefronts deben resolverse por jugador — horario, audiencia y precio computados del lado del servidor, nunca cálculo de elegibilidad en el cliente.
  • Las reglas de precios pertenecen a un solo Hook comprobable — descuentos, recotización y vetos corren antes de cualquier cargo.
  • Los recibos deben ser a prueba de repetición, y un reembolso debe revocar el entitlement a través de los mismos Events que usó la concesión.
  • Sáltatelo cuando nunca se venden ítems — aunque las recompensas igual aterrizan por el único Grant de commerce con origen reward (los cofres de ciclo de Leaderboards llegan así), de modo que hasta un juego sin tienda conserva un único libro de concesiones auditable.

Quién hace qué

ActorEn esta página
playernavega los storefronts, compra, gestiona la billetera, canjea códigos
sellerconfigura el catálogo, los precios y los horarios de storefront
backend-servicevalida recibos; recotiza u otorga mediante Hooks de compra

De un vistazo

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

El modelo

Qué declara un ítem de catálogo.

DeclaraQué es
keyes contenido escrito, direccionado por una clave para que un renombrado en código sea un renombrado
kindconsumable — se gasta; o durable — se posee una vez
pricesun precio es una cantidad monetaria: un entero en unidades menores más un código de moneda, nunca un flotante. Un ítem puede llevar varios — moneda de juego y moneda real, ambas
external identifier per provideruna ranura por proveedor, declarada, porque una tienda conoce el ítem por su propio id
what it points atopcionalmente una Entity de cualquier clase declarada, y comprar el ítem otorga entonces la propiedad de esa Entity

Qué puede ser la composición.

Qué es
what is purchasableun ítem de catálogo, nunca una Entity arbitraria: un precio sin nadie que lo sirva no es una promesa, porque una compra necesita a alguien que otorgue el derecho y responda por el reembolso
two levels, no thirdun paquete es un ítem de catálogo hecho de ítems; un storefront es un conjunto de ofertas, y una oferta apunta a un ítem y puede sobrescribir su precio y el contenido de un paquete
a territorial pricese expresa mediante un storefront y no sobre el ítem

Qué declara un storefront.

DeclaraQué es
offersel conjunto, cada una apuntando a un ítem
scheduleen tiempo de reloj de pared, siempre UTC: cuándo abre y cierra la ventana
audienceun predicado, no una lista de jugadores — así que la audiencia es una regla que sigue siendo verdadera y no un snapshot

Un storefront en cuya audiencia un jugador no cae no existe para ese jugador.

Los estados de un pedido.

DesdeHasta
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid o grantedrefunded
SiempreQué es
the pricese fija en el pedido en el momento en que se crea, así que un cambio de precio posterior no puede alterar lo que se acordó
awaiting paymenttiene un plazo declarado, declarado por proveedor, porque difieren
grantingestá separado del pago: paid y granted son estados distintos: que llegue el dinero y que aparezca la cosa son dos hechos, y confundirlos esconde cuál de los dos falló
a refundes una transición externa: llega sin ninguna petición nuestra, en cualquier momento, y qué pasa con lo que se otorgó está declarado — hay tres respuestas y ningún valor por defecto
an entitlementlleva su origen — una compra, un código promocional, una recompensa, un regalo — así que «de dónde salió esto» se puede responder un año después
a consumable entitlementse acumula: cambia por un incremento con clave de idempotencia, nunca sobrescribiendo lo que se leyó
ownershipes un predicado de dueño: un entitlement le pertenece a un jugador por el mismo mecanismo que cualquier fila con dueño
the catalogse declara en código y llega al panel bajo el modo de propiedad seed por defecto: el código crea lo que falta, y las ediciones de un diseñador sobreviven al siguiente push
provider secretsviven en el plano del operador, nunca en la Declaration, y nunca en un repositorio
a provider's capabilitiesestán declaradas: si tiene siquiera una API usable, y qué puede hacer — así un catálogo no promete un flujo que la tienda no puede servir

Cada punto de extensión nombra el tipo que le entrega al Hook: una intención de compra antes de la compra — jugador, oferta, proveedor, precio — y la compra misma después. Un Hook nunca recibe una bolsa sin tipos.

Errores

  • Fuera de la audiencia responde not found, y repetir no sirve de nada. Fuera del horario también responde not found, pero vale repetir en cuanto abra la ventana.
  • El proveedor no está disponible y el proveedor rechazó el pago son deliberadamente respuestas distintas: la primera es unavailable y reintentable con espera creciente, la segunda un conflicto que reintentar no arreglará. Colapsarlas haría que quienes llaman reintenten un rechazo para siempre.
  • Un recibo inválido es un rechazo de validación; un recibo ya consumido por otro pedido u otro jugador es un conflicto — eso es lo que vuelve inútil la repetición.
  • El precio cambió entre leer el storefront y comprar es un fallo de precondición: vuelve a leer y decide de nuevo, en vez de que te cobren el precio nuevo en silencio.
  • Moneda de juego insuficiente es un conflicto, no un forbidden — el permiso de comprar se se tiene, el saldo no está. Vale repetir tras recargar.
  • Un entitlement duradero que el jugador ya tiene es un conflicto.
  • La región o la edad no permiten la compra responde forbidden, y repetir no sirve de nada.
  • El plazo del pedido venció es un conflicto: crea un pedido nuevo.
  • El límite de gasto agotado responde como conflicto o como límite de tasa según cuál límite haya sido, y enuncia cuándo se reinicia el límite.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • El tamaño del catálogo — publicar otro ítem se rechaza como conflicto.
  • Tiendas por Project — la creación se rechaza.
  • Ofertas en un storefront — una adición se rechaza; el storefront nunca se trunca en silencio.
  • El tiempo de vida de un pedido a la espera de pago — una transición a expired, con un Event.
  • La tasa de intentos de compra — un límite de tasa con un plazo.
  • El límite de gasto por periodo — un conflicto que enuncia cuándo se reinicia el límite.
  • Retención de pedidos — pasado el periodo un pedido se vuelve ilegible por el periodo declarado en vez de desvanecerse sin explicación.
  • Entitlements por jugador — una concesión se rechaza, y los ya otorgados nunca se desalojan.
  • La precisión de un precio no es un límite sino un tipo — un entero en unidades menores.

Flujo del usuario

La primera compra de un jugador nuevo: el storefront se resuelve, el precio se parte a la mitad, el ítem aterriza — y la venta llega al embudo de primera compra que el operator lee en Analytics.

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Inventory

Todo se integra aquí. Los disparos descuentan munición, los drops aterrizan en él, las abilities lo consultan, el movimiento se ve modificado por él — un solo conjunto de filas con dueño, con pilas que se incrementan y un tope por dueño cuyo comportamiento en la frontera eliges tú.

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

Cuándo usarlo

  • Los jugadores tienen cosas, y una tenencia es una fila con un dueño — leída por dueño, topada por dueño, con el comportamiento de desbordamiento declarado en vez de dado por defecto.
  • Una cantidad se acumula — una pila cambia por un incremento con clave de idempotencia, así que un débito reintentado no debita dos veces.
  • Otros módulos gastan de un mismo conjunto — los disparos descuentan munición, los drops otorgan botín, las compras aparecen como filas contra su entitlement.
  • Sáltatelo cuando el número no es poseíble — hp, xp y cooldowns pertenecen a stats.

Quién hace qué

ActorEn esta página
playerlee sus propias tenencias y gasta de ellas
backend-serviceotorga, incrementa y revoca en nombre de un jugador, nombrando al jugador por el que actúa

De un vistazo

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

Una sesión de jugador ejecuta las lecturas, los movimientos y la comprobación de si le alcanza con las mismas llamadas. Otorgar, consumir y destruir no le corresponden: la plataforma se los rechaza como prohibidos y nombra el derecho que le falta a quien llama, sea cual sea el binding que hizo la llamada.

El modelo

Un inventario no introduce nociones propias. Es un preset — una forma ensamblada a partir de lo que Entity ya da, así que todo lo de abajo es una Declaration de Entity y no un mecanismo de esta página. Un preset que necesitara una clase nueva de Declaration sería un hueco en el contrato, no una razón para extender el preset.

DeclaraQué es
un tipo con dueñola tenencia le pertenece a un dueño, y la selección por dueño es una operación propia de Entity
una ref a un ítem de catálogola referencia guarda el id del ítem y nunca su key, que es exactamente lo que vuelve seguro renombrar una clave. La definición misma vive en Commerce
un aspecto de pila con un incrementouna pila cambia por un Delta y no sobrescribiendo lo que se leyó. El incremento no es idempotente por naturaleza — dos incrementos son dos incrementos — así que está obligado a aceptar una clave de idempotencia, y la forma de establecer el desenlace es una lectura direccionada
un tope por dueño con su comportamiento de fronterauno de tres, y no hay valor por defecto: refuse · redirect a un depósito de dueño declarado · discard with event

Qué vale para toda tenencia.

SiempreQué es
the cap has no defaultlas tres respuestas a una bolsa llena son tres juegos distintos: un rechazo pierde el botín delante del jugador, un redireccionamiento es correo o un almacén que desborda, un descarte es una pérdida silenciosa que sólo es lícita porque fue declarada y es observable. Ninguna es la correcta para las tres, así que la declaración elige
the owner is immutablenada cambia de manos editando un campo: una tenencia se mueve como una revocación más una nueva concesión con un origen declarado, y ambos hechos quedan en el registro. Editar al dueño borraría el rastro y dejaría «de dónde saqué esto» y «me lo quitaron» sin nada detrás del estado actual
a transfer between two playerses una promesa distinta: necesita depósito en garantía y antifraude, y queda fuera de esta versión
a rowrepresenta un entitlement en vez de ser una segunda fuente de uno — lo que se compró vive en Commerce, y la fila de aquí lo representa

Errores

  • La instancia no existe, o un predicado la esconde — la respuesta es not found en cualquiera de los dos casos, así que un rechazo nunca revela que algo existe pero no es tuyo.
  • Un campo no declarado en el aspecto (anidados incluidos), y un campo obligatorio sin valor, son rechazos de validación que nombran el campo.
  • La versión no coincidió es un fallo de precondición, que vale repetir tras volver a leer.
  • Una escritura en nombre de un jugador que no nombra al jugador es un rechazo de validación, no una escritura silenciosa como si fuera de otro.
  • Sin permiso para una lectura o una escritura responde forbidden, con la lectura y la escritura distinguidas.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Instancias por dueño — según la regla declarada de arriba, y no hay valor por defecto.
  • El tamaño de la instancia almacenada — la escritura se rechaza como conflicto, y el rechazo nombra el campo culpable y el tamaño medido. El techo se alcanza por acumulación, así que el acercamiento a él es observable antes de la escritura que falla.
  • La tasa de cambios sobre una instancia — un rechazo por límite de tasa con un plazo.
  • El tamaño de página de la selección — la página se recorta al tope y la marca de «hay más» sigue siendo verdadera; devolver menos sin la marca está prohibido.

Flujo del usuario

La munición de un disparo, del lanzamiento que la descuenta al drop de la caja que la devuelve. La ability, el projectile, el bloque de Stats de la caja y la drop-table que hay en ella son entity presets — Declarations sobre Entities, no módulos propios.

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Leaderboards

Cada mecánica, sistematizada. No un catálogo de tipos de tablero. Un solo modelo cuyos ejes se componen en todos ellos: clasificaciones diarias, tableros de mejor vuelta, totales de gremio, temporadas, torneos.

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

Lee ese bloque así: quién actúa en esta página (actors), qué te entrega el módulo (provides), sobre qué módulos se para (builds-on), y de dónde cuelga respecto de la raíz — mounts: root significa playserv.Leaderboards, no un espacio de nombres debajo de otro módulo (cómo se montan los módulos).

Cuándo usarlo

  • Los puntajes deben ordenar a los jugadores — clasificaciones diarias, tableros de mejor vuelta, totales de gremio — como un solo modelo declarado, no un sistema por tablero.
  • Necesitas las lecturas estándar — top-N, alrededor-de-mí, una lista nombrada de dueños — sin modelado de datos adicional.
  • Los ciclos deben cerrarse a horario, archivar (nunca borrar) y disparar un Hook de recompensa con la tabla final.
  • Los puntajes sospechosos nunca deben entrar a la tabla — un Hook previo al envío valida, recorta o rechaza con una razón tipada.
  • Un torneo es el mismo tablero con una ventana de inscripción, un máximo de inscritos e intentos por ciclo.
  • Sáltatelo cuando el número nunca se compara entre jugadores — un contador personal o un total de carrera son datos corrientes. El módulo ordena resultados; nunca los computa, y no ejecuta ningún cuadro de eliminación.

Quién hace qué

ActorEn esta página
playerlee top-N/alrededor-de-mí/su propio rango, se suscribe a los cambios de rango
backend-serviceenvía resultados; los corrige o los rechaza en el Hook previo al envío; otorga recompensas cuando se cierra un ciclo
operatordeclara tableros; cierra un ciclo antes de tiempo, corrige registros (auditado), vigila las tasas de envío

De un vistazo

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

La Declaration vive al lado del resto de tu schema — en el proyecto de servidor, o en el proyecto de UE o de Unity — y playserv push la compila y la manda arriba: el tablero aparece en el panel, vacío, con su siguiente reinicio ya programado. Las programaciones son en UTC, así que este tablero cierra el lunes 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") mueve la hora. La hora local por jugador no es una opción de reinicio — una tabla no puede cerrar en veinticuatro momentos distintos.

La clave de orden es una lista, no un puntaje más un desempate. Los campos ordenan en el orden en que los numeras, cada uno con su propia dirección, y el último nivel es de la plataforma: con claves iguales, el envío anterior queda más arriba, así que dos carreras idénticas nunca se intercambian de lugar entre dos lecturas. Un campo fuera de la clave — Map aquí — se lleva para mostrarlo y nunca mueve una fila.

Agg dice qué le hace un segundo envío al único registro que un dueño tiene en el ciclo actual:

AggUn segundo envíoIdempotente
Setreemplaza el registro por los valores enviadossí
Bestlo reemplaza solo cuando los valores nuevos quedan más arriba según la clave de ordensí
Incrementsuma los valores enviados al registro — bajas, vueltas, contribución al gremiono — lleva una clave de idempotencia
Decrementlos restano — lleva una clave de idempotencia

Un envío que no le gana a un registro Best no es un error: vuelve aceptado, con el orden sin cambios. Increment y Decrement son los dos que una llamada reintentada aplicaría dos veces, así que toman la misma clave de idempotencia que cualquier otra escritura reintentable.

Enviar es una sola llamada, y en este tablero viene de código de servidor porque así lo dijo la Declaration:

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

Tres cosas de esa llamada vale leerlas por separado:

En la llamadaQué es
PlayServ · playservel handle de función en la nube y la instancia de cliente que el SDK te entrega al iniciar. La misma API, dos llamadores — en Go son ps y psv, y cada snippet usa el que su llamador tiene
quien envíala función dueña del resultado del partido. En Tanks es el Hook on dispose de la Room (Rooms), que corre con el estado final en la mano
playerIdel id de jugador de la plataforma, de Auth & Players, nunca un nombre que elegiste: un Hook lo lee de su payload (e.By.PlayerId en la lección), y un host de Room envía el id del asiento que posee

Los valores son los campos que nombró la Declaration — un campo no declarado se rechaza, no se guarda.

Las lecturas que todo juego necesita, y la suscripción que las mantiene al día:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) es una ventana por rango, no una página: cinco filas encima de ti, cinco debajo, más la tuya — once filas, recortadas simétricamente donde termina la tabla, así que el rango 2 recibe una ventana más corta de ambos lados y no una desplazada. Top está paginado: devuelve las primeras N filas y un cursor, y after: recorre el resto.

ForOwners es cómo funciona un tablero de amigos. La plataforma no guarda ningún grafo de amigos; tú pasas los dueños que tu juego ya tiene — los miembros de un Group, o una lista de ids de tus propios datos — y cada fila vuelve con su rango en la tabla completa, no un rango dentro de la lista.

OnRankChanged entrega el rango propio del jugador local y nada más: un tablero con cincuenta mil inscritos no le empuja cada reordenamiento a cada cliente. El callback recibe la fila cambiada — rango, los campos ordenados, los campos de visualización — y Cancel() termina la suscripción. El rango en sí es un snapshot: dos lecturas con un segundo de diferencia pueden diferir mientras aterrizan envíos, aunque tu propio envío siempre es visible para tu propia siguiente lectura.

El modelo

Qué declara un tablero.

EjeValoresCómo lo fijas
Dueñojugador · GroupOwner = Owner.Player — un tablero de gremio es el mismo tablero con Owner.Group
Clave de ordenuno o más campos declarados, cada uno ascendente o descendente[Rank(1, Sort.Descending)] int Score
Agregaciónset · best · increment · decrementAgg = Aggregation.Best
Reiniciouna programación en UTC; un ciclo vence, nunca borraReset = Reset.Weekly(DayOfWeek.Monday)
Quién puede enviarsolo el servidor (el valor por defecto) · los jugadoresSubmit = Submit.ServerOnly
Campos de visualizacióndeclarados y tipados; nunca parte del orden[Display] string Map
Lista de dueñoselegida en tiempo de lectura, no declaradaForOwners("weekly-score", ids) — amigos, un gremio, un lobby
Reglas de torneoventana de inscripción · máximo de inscritos · intentos por ciclo · entrada obligatoriaRules = Tournament.Define(…), en la tabla bajo Torneos

No hay eje de alcance: un tablero por región, por Room o por temporada es un tablero por clave, y la clave es lo que referencia tu código.

Qué vale para todo tablero.

SiempreQué es
direction and operatorson inmutables tras la primera escritura: cambiarlos reordenaría el historial en silencio; la forma de cambiar una mecánica es una generación nueva, no una edición
exactly one entry per owner per generationun segundo no es una segunda fila
an entryno es una Entity: sin ciclo de vida propio, sin máquina: la crea el primer envío y la cambia el operador que declaró el tablero
fields outside the order key never affect the orderson visualización, y por eso se declaran aparte
a generationvence, no borra: open → expired → evicted from retention, y las generaciones vencidas siguen siendo legibles durante el periodo de retención declarado
the schedule transitiones observable por un Event, así que un manejador lee exactamente la tabla que cerró y no la vacía que acaba de abrirse
the default submitteres el servidor: quién puede enviar se declara, y el valor por defecto no es el jugador
a boardes contenido escrito: declarado en código, direccionado por una key, llegando a la consola administrativa, bajo el modo de propiedad seed para que las ediciones de programación de un diseñador sobrevivan al siguiente push
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

Qué es un ciclo, y qué hace cerrar uno.

Qué es
a resetcierra un ciclo en vez de borrarlo
a closed cycledeja de tomar envíos y sigue siendo legible bajo su etiqueta — Top("weekly-score", 100, cycle: label), un parámetro de lectura y no un trabajo de exportación
the close eventlleva esa etiqueta, así que un manejador lee exactamente la tabla que cerró y no la vacía que acaba de abrirse

Los dos Hooks de un tablero, y sus tipos difieren.

HookQué puede hacer
pre-submitun gatekeeper: la plataforma lo llama y espera. Puede corregir los valores enviados contra tus propias Entities, recortarlos, o rechazar con una razón tipada, y si falla el envío se rechaza — falla cerrado. No puede cambiar el dueño del registro ni su tablero: eso ya está reclamado. Devuelve un veredicto — aceptar, aceptar un envío corregido, o rechazar — y el rechazo llega a quien llama como un problema tipado (Core), la misma forma que toma cada rechazo del SDK
cycle-closedun observer: se dispara a posteriori, no puede vetar, y un fallo ahí deja el ciclo cerrado
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

La recompensa es una concesión de Commerce y no una mecánica de este módulo: chest.gold es un id de catálogo, y el origen reward es lo que separa la concesión de una compra — los reembolsos, la revocación y el Event de entitlement cambiado funcionan sobre ella exactamente igual que sobre un ítem comprado.

Torneos. Un torneo es este tablero más restricciones de participación — no hay un segundo mecanismo ni una Entity aparte. Las cuatro restricciones, con sus unidades y su comportamiento en la frontera:

RestricciónDeclarada comoEn la frontera
ventana de inscripciónentryWindow: TimeSpan — cuánto sigue abierta la entrada tras abrirse el ciclouna entrada posterior a su cierre se rechaza; el ciclo igual corre hasta su reinicio
máximo de inscritosmaxEntrants: int — registros en un cicloel inscrito 65 de 64 se rechaza como conflicto, y no se desaloja nada — un tablero que soltara sus peores filas ordenaría a quien llegó primero
intentos por cicloattemptsPerCycle: int — envíos por dueñoel siguiente envío responde «intentos agotados» — un conflicto, no un error de permiso, y el contador se reinicia con el ciclo
entrada obligatoriajoinRequired: true — los inscritos son una membresía, no todo el que juegaun envío de alguien no inscrito se rechaza

Un torneo diario declara las cuatro de punta a punta.

Errores

Una sesión de player que llame a una operación que este tablero reserva para fn — un envío a un tablero de solo servidor, un cierre temprano de ciclo — se rechaza como error de permiso antes de que se escriba nada; la misma llamada desde una función en la nube pasa. Un envío a un ciclo que ya cerró es un conflicto en cambio: el derecho está, el ciclo no, y el reintento es un envío al actual.

Límites

Todo límite con lo que pasa en su frontera.

LímiteEn la fronteraNúmero
filas por lecturala página se recorta, «hay más» sigue verdadero, after: continúatope de página fijado por Project
ventana alrededor de un dueñorecortada simétricamentetope de ventana fijado por Project
registros en un cicloel envío se rechaza como conflicto; sin desalojomaxEntrants por tablero; sin cota cuando no se fija
intentos por dueño por cicloconflicto «intentos agotados», resuelto por el reinicioattemptsPerCycle por tablero; sin cota cuando no se fija
tasa de envío por dueñorechazo por límite de tasa que lleva el momento en que se permite un reintentotasa fijada por Project
tableros por Projectuna Declaration nueva se rechaza en el deploylímite fijado por Project
retención de ciclos cerradosel ciclo sale del almacenamiento con un Event; las lecturas responden después not-foundventana de retención fijada por Project

Flujo del usuario

Una semana del tablero weekly-score: envíos del lado del servidor, una lectura de alrededor-de-mí, el cierre del lunes y sus recompensas.

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Files & UGC

Los archivos llegan por trozos y se procesan a medida que llegan. Subidas, activos y sus variantes derivadas, y contenido generado por jugadores con una ruta de moderación.

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

Cuándo usarlo

  • Jugadores o servicios suben blobs — sesiones por trozos, reanudables, con cuotas legibles por jugador.
  • El procesamiento debe empezar antes de que la subida termine — lee el archivo como un Stream, trozo a trozo.
  • El contenido hecho por jugadores necesita una ruta de moderación — SubmitUgc, una cola, un veredicto, Hooks en ambos extremos.
  • Una imagen maestra debe servir a muchas plataformas — deriva variantes (redimensionar, transcodificar) y conserva el original como canónico.
  • Sáltatelo para payloads pequeños y estructurados — un campo de registro de Data los lleva sin sesión de subida.

Quién hace qué

ActorEn esta página
playersube trozos, lee archivos por Stream, envía UGC
moderatorrevisa la cola, aprueba o rechaza envíos
backend-servicederiva variantes de activos; engancha la subida y la moderación; fija cuotas

De un vistazo

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC, la ruta del jugador:

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

Las compuertas a su alrededor son Hooks, el mismo contrato que en todas partes:

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Ambos Hooks envuelven un paso de operación, nunca un Event: Before(Files.Upload) decide si la subida empieza, After(Files.SubmitUgc) corre una vez que el envío existe y lo pone en la cola de moderación mediante el propio Moderation.Enqueue del módulo. Los Events — subida completada, variante lista, UGC enviado, veredicto de moderación — van a los suscriptores, y un suscriptor no veta nada.

El modelo

Un archivo son bytes opacos más metadatos declarados — origen, tipo de contenido, tamaño — y no es un almacén de estado sobre el que se tomen decisiones. Su vínculo con el modelo del juego corre en la otra dirección: un campo de tu tipo lleva la referencia; el archivo no sabe nada del juego.

Qué declara una clase de archivo.

DeclaraQué es
origincontenido escrito, generado por el juego, o generado por el usuario — y de ahí se siguen los límites de tamaño y las políticas
admissible content typescomo una lista declarada, nunca olfateados de los bytes
size limitscomprobados cuando se abre la sesión, por el tamaño declarado, y no en la última parte
part size and orderla subida se realiza mediante una sesión: un tamaño de parte declarado, el orden de las partes, un punto de reanudación
derivativesopcionalmente, variantes con nombre producidas por un manejador — y la disponibilidad de cada variante declarada es observable, así que un cliente nunca adivina si la miniatura ya existe
storage prefixsobre el campo de schema que lleva la referencia: dónde viven los bytes, y nada más. No es un directorio — no hay renombrar, no hay mover, no hay permisos sobre un prefijo, no hay operación recursiva. El archivo se sigue direccionando por su id o su key, y el prefijo no participa de eso

Qué vale para todo archivo.

SiempreQué es
completiones idempotente por sesión: una finalización repetida devuelve el mismo archivo y no un segundo
a checksumes obligatoria, y un desajuste es un rechazo, nunca una aceptación silenciosa de bytes corruptos
a published filees inmutable: una edición es una versión nueva, y una referencia a una versión sigue apuntando a lo que apuntaba
authored contentse direcciona por clave más versión, y es managed: no se edita en la consola administrativa, porque el código es su dueño
an unfinished sessionmuere de forma observable: pasado su plazo se la termina con un Event y sus partes se liberan
ownershipsigue al predicado de dueño: los archivos generados por usuarios y por el juego tienen dueño como cualquier fila con dueño, y los archivos de un dueño obedecen a la política de borrado de jugadores — cascada, rechazo o anonimización, declarada en vez de supuesta
read accesspuede depender de un derecho: un activo pago se restringe con el derecho de Commerce y no con un segundo sistema de permisos

Cada punto de extensión nombra el tipo que le entrega al Hook — la intención de subida antes de la subida, el envío después — así que un Hook nunca recibe una bolsa sin tipos.

Errores

  • Sin derecho responde not found, no forbidden — de lo contrario la lista de rechazos revela qué complementos existen. Un archivo retirado responde igual.
  • Una sesión vencida es un conflicto: abre una nueva.
  • Una parte fuera del orden o del tamaño declarados, un tipo de contenido no declarado, y un tamaño por encima del límite son rechazos de validación — y el del tamaño cae cuando se abre la sesión, no después de que los bytes hayan viajado.
  • Un desajuste de checksum es un rechazo de validación que vale repetir: reenvía la parte.
  • La cuota agotada es un conflicto, reintentable tras liberar espacio.
  • Una concesión de lectura vencida responde not authenticated — pide una concesión nueva en vez de tratarlo como un problema de permisos.
  • Que la revisión repruebe el envío es un veredicto, no un rechazo: el envío se consideró y la respuesta es negativa con una razón declarada, así que qué hacer después depende de la razón.
  • La tasa de subida excedida responde en la categoría de límite de tasa con un plazo.

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • El tamaño de un archivo según su origen — la subida se rechaza antes de que se acepte parte alguna, no en la última.
  • El tamaño de la parte — la parte se rechaza como fallo de validación.
  • El tiempo de vida de la sesión — expired con un Event, y las partes se liberan.
  • Cuota de almacenamiento por Project y por jugador — una sesión nueva se rechaza como conflicto, y lo ya publicado nunca se borra en silencio para hacer sitio.
  • Versiones de contenido escrito conservadas — se retira la más vieja, y una versión referenciada por un Environment en vigor nunca se retira.
  • La tasa de subida por Actor — un límite de tasa con un plazo.
  • Retención del contenido generado por el juego — pasado el periodo, un retiro con un Event.

Flujo del usuario

Un nivel construido por un jugador, del primer trozo subido al veredicto de aprobación.

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Analytics

Todo lo que hay que contar después en vez de ver ahora. Declara un Event de telemetría tipado, emítelo, y aterriza al lado de los propios de la plataforma — un nivel completado, un paso de embudo, un evento económico, la duración de una sesión, un abandono en el tutorial. Este módulo emite; no lee, no agrega, y no manda nada a ninguna parte por sí mismo — la dirección en que viaja un lote es del enrutador, en Extensibility.

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

Cuándo usarlo

  • Algo debe contarse después — un paso de embudo, un nivel completado, un evento económico, la duración de una sesión.
  • La comparación tiene que sobrevivir a los builds del juego — un tipo lleva una versión de schema, así que un embudo de hace un año no es en silencio un empalme de dos significados distintos de un mismo campo.
  • El volumen es alto y una fila perdida es aceptable si así lo dijiste — la telemetría es el único lugar del contrato donde la pérdida declarada es lícita.
  • Sáltatelo cuando alguien tiene que reaccionar — un Event de telemetría no tiene suscriptores en absoluto; un hecho que otros deben oír es un Event de juego.

Quién hace qué

ActorPuedeNo puede
any actordeclarar tipos en el schema; emitir en su propio nombre, de a uno o en lote; leer los tipos declaradoscompletar el contexto; leer, consultar o agregar lo emitido
backend-servicelo mismo, y emitir en nombre de un jugador por delegaciónleer telemetría — no hay permiso de lectura, porque no hay operación de lectura

De un vistazo

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

La definición es schema, así que lo que llega lleva campos nombrados y tipados en vez de un amasijo JSON — y se declara en su propia forma portadora, no como un Event de juego con una bandera, de modo que cuál de los dos es se puede saber por la Declaration sin ejecutar nada.

El modelo

Qué declara un tipo de Event de telemetría.

DeclaraQué es
nameel del propio tipo
fieldstipados por el sistema de tipos de la plataforma; una máscara de campos les aplica como en todas partes
schema versionobligatoria, y no derivada de la versión del SDK — un embudo compara eventos recolectados bajo builds distintos del juego, y sin una versión la comparación mezcla en silencio lo incomparable
samplingqué proporción de eventos de este tipo pasa. Declarada en el tipo, nunca elegida por la implementación según la carga: una proporción que cambia sola vuelve incomparables los embudos entre días, y eso se nota solo después de haber tomado decisiones sobre ellos
loss tolerancesi este tipo tolera pérdida. La telemetría es el único lugar del contrato donde la pérdida declarada es lícita
deletion behaviourcómo el borrado de un jugador alcanza a este tipo — borrando o anonimizando. El estudio declara la política; el módulo la lleva a cabo

Qué vale para todo Event de telemetría.

SiempreQué es
no addressing targetsin destinatario, sin Group, sin suscripción. Querer dirigirlo a alguien es la señal de que lo que hace falta es un Event de juego
contextes de la plataforma: agrega el Actor o una marca de anonimato, la sesión, el Environment, la versión del build, y el momento según el reloj declarado. Quien llama no puede completarlo: un llamador que sustituya el Actor o la versión del build recibe los valores derivados en vez de los pasados
the sampling shareviaja con el evento: sin ella el número absoluto no puede reconstruirse a partir de lo que llegó
losses observable en agregado: la proporción no entregada a lo largo de un periodo está disponible para el consumidor; la observabilidad por elemento no está prometida, porque a volúmenes de telemetría un mensaje por pérdida se volvería él mismo un flujo
emissionno es idempotente: dos llamadas son dos hechos, y no acepta clave de idempotencia — suprimir la segunda perdería datos. No hay forma de establecer el desenlace de una emisión perdida, y no se quiere ninguna: la tolerancia a la pérdida se declara por adelantado, para todas las llamadas de una vez
the eventsson el historial: el módulo no guarda ninguno propio

Errores

  • Un tipo no declarado, y un campo que no cumple el schema del tipo, son rechazos de validación — ninguno de los dos es una sorpresa en runtime, porque un tipo llega a la consola administrativa desde su Declaration.
  • Un evento sobredimensionado es un rechazo de validación; los campos nunca se recortan en silencio.
  • La tasa excedida responde en la categoría de límite de tasa, llevando el tiempo antes del cual reintentar no sirve de nada.
  • Un descarte por muestreo no es un error, y una pérdida admisible tampoco. La llamada se ejecutó y el descarte es comportamiento declarado; reportar cualquiera de los dos como fallo volvería el comportamiento declarado indistinguible de una falla.
  • Un lote es entero o por elemento, y cuál de los dos está declarado — nunca «lo que haya pasado».

Límites

Cada techo nombra su comportamiento en la frontera; los números que hay detrás llegan con el capítulo de límites de la plataforma.

  • Tasa de eventos por Actor — un rechazo por límite de tasa con un plazo. Excederse de la tasa nunca descarta en silencio: o ese rechazo, o un descarte por muestreo declarado, y no hay un tercer desenlace.
  • Tamaño de un evento — un rechazo de validación, nunca campos recortados en silencio.
  • Tamaño del lote — rechazado antes de enviarse en vez de aplicado parcialmente.
  • Tipos declarados por Project — una Declaration nueva se rechaza en tiempo de deploy, no en runtime.
  • Campos en un tipo — lo mismo, en tiempo de deploy.
  • Periodo de retención — una vez vencido el evento no está disponible por el periodo declarado.

Flujo del usuario

El golpe mortal se vuelve un Event de telemetría tipado, muestreado por su Declaration y contado después. La ability y el Stat que lo llevan son entity presets, no módulos.

Beyond the SDK

El plano del operador

Lo que deliberadamente no está en el SDK. El ciclo de vida de Projects y Environments, el deploy y el rollback, la facturación, la administración de organización y usuarios, el enrutamiento del clúster — todo eso le pertenece al panel administrativo, a la CLI y a la superficie MCP, no al código del juego. La única excepción deliberada es Schema as Code: el schema es una superficie de desarrollador, así que está en el SDK.

Un modelo, dos planos

El plano del SDKEl plano del operador
Se alcanza desdeel código del juegoel Panel de Control, la CLI, MCP
SostieneRooms · Entities · jugadores · comercio · leaderboardsProjects y Environments · deploy y rollback · facturación · administración de organización y usuarios · enrutamiento del clúster
Trabaja enDeclarations, Hooks, Events, Operationslas propias pantallas del panel

Comparten un modelo: la Declaration que envías es la que dibuja el panel. Lo que no cruza la línea es la autoridad — el código del juego no puede desplegar, facturar, ni mudar a un inquilino.

Cada superficie del SDK — cada módulo, y los presets declarados sobre Entities — tiene una contraparte de operador en el Panel de Control, donde las mismas Declarations se ven y se editan desde el otro lado:

Superficie del SDKEl operador ve
Schema / DataEntities, migraciones, el navegador de registros, vistas guardadas, importación/exportación
Entitymáquinas de estados, inspección por instancia
Entity Presetstablas de drop, definiciones de ability, Stat y projectile, presets de objetos del mundo — ajustables en vivo
Accessla grilla de roles: roles × operaciones, filtros de fila, máscaras de columnas
Extensibilitycadenas de escenario con sobrescrituras, orden resuelto, trazas de invocación
Roomsla flota: Rooms, salud del Tick, colocación, estado de drenado
Matchmakingcolas, tickets en curso, curvas de relajación
Commercecatálogo, programación de storefronts, recibos, reembolsos
Leaderboardsciclos, corrección de registros (auditada), tasas de envío
Authproveedores, sesiones, baneos, el escenario de auth
Filesactivos, colas de revisión de UGC, cuotas
Analyticstableros, reenviadores, retraso de ingesta
Mapmapas y conjuntos de obstáculos, instancias vivas
Visibility / Collision / Locomotion / Predictionajuste por Room: reglas, pares de respuesta, ventanas, costo de paquete por Actor
Groups / Messagingnavegador de Groups, plantillas, filtros de moderación, horarios
Botsperfiles, cuotas de llenado, endpoints de cerebros
Inventory / Profiletenencias y transferencias, vistas y conjuntos de Entities con dueño

La regla de diseño. Una capacidad del SDK sin superficie de panel es invisible para live ops; una superficie de panel sin capacidad del SDK es una mentira. Los módulos publican ambas mitades juntas, y una Declaration escrita en cualquiera de los dos lugares es el mismo modelo en ambos.

El viaje de una Declaration: el schema-author la escribe, playserv push la lleva, el panel la dibuja para el operator, y el reajuste aterriza en Rooms que ya están corriendo.

Acceso de agentes

Todo lo que muestra el panel es también alcanzable por herramientas: la plataforma expone una superficie MCP (la misma API que usa el panel), así que los agentes de IA y los scripts operan Projects (bootstrap, schema, registros, jugadores, deploys) bajo el mismo modelo de acceso que cualquier otro Actor.

Beyond the SDK

Bajo el capó: el transporte y el hub

Una referencia de arquitectura, no una superficie que llames. Nada de esta página aparece en la API contra la que escribes: no hay socket que abrir, ni canal que elegir, ni sobre que llenar, ni reintento que programar. El código de tu juego nunca se topa con la maquinaria de esta página — esa es la gracia. Core Concepts nombra la pila; el mecanismo vive solo aquí. Está aquí para que un arquitecto pueda comprobar qué hace el SDK con una conexión caída, un módulo deshabilitado o un mensaje que debe llegar exactamente una vez.

La pila de capas

The runtime stack from your code down to the transports, with the line below which you never call anything

Cinco capas, de arriba abajo: el espacio de usuario, los módulos, los Primitives, el hub, y los adaptadores de transporte debajo de él. Las dos de arriba son espacio de usuario; todo lo de abajo es asunto propio del SDK.

  • El espacio de usuario es tu código. Ve módulos, y el vocabulario se detiene ahí.
  • Los módulos son la capa aplicada: Rooms, Matchmaking, Inventory, Leaderboards. Forman un grafo, no un árbol, que es lo que el hub tiene que resolver cuando uno de ellos se apaga (Inheritance & Composition es la forma en sí).
  • Los Primitives son la primera implementación que todo referencia: Data, Events, RPC, Groups. Un módulo es un ensamblado nombrado de Primitives más sus propias reglas.
  • El hub es el controlador: inyección de dependencias, montaje de módulos, la sesión de usuario, la recuperación de estado, la calidad de servicio de los mensajes, y el enrutamiento de cada mensaje entrante al módulo montado para él.
  • Los transportes son adaptadores sobre un protocolo. Existen varios; el hub los trata igual.

Los transportes son adaptadores

Habrá más de un transporte, y difieren de maneras que de otro modo se filtrarían a cada módulo:

EjeRango
Formadirigido por mensajes o dirigido por peticiones
Canalesde un solo canal o de varios canales
Estadocon recuperación del estado de conexión, o sin ella
ProtocoloTCP o UDP

Hoy eso significa WebSocket, el transporte UDP de PlayServ, y HTTP a secas. Cada uno es un adaptador detrás de sus propios detalles de implementación, y cada uno expone lo mismo hacia arriba: una sesión de transporte. El hub mantiene una sesión, nunca un socket, así que nada por encima del adaptador razona sobre la interfaz de red.

Una frontera declarada. Un canal de transporte y una sesión de transporte a la vez. Ejecutar un transporte de backend para leaderboards mientras un transporte de master-client lleva la sesión viva queda fuera de alcance, y la API no lo promete — ninguna firma tiene sentido solo con varios canales abiertos. Si una versión posterior lo abre se zanja con la superficie invariante por Project; hasta entonces la forma de sesión única es el contrato.

El hub oculta el transporte por completo

Hacia abajo, el hub habla la interfaz de transporte. Hacia arriba, ofrece estado, Events y la sesión de usuario. El código de módulo y el código de juego son igualmente incapaces de saber qué transporte hay debajo, o cómo el hub agrupó una llamada, o qué hizo para volver a un estado consistente tras un hueco.

  • La sesión de usuario le pertenece al hub, no a un módulo. La reconexión, la reanudación y la recuperación de estado ocurren una vez, en el hub, para todo lo montado sobre él.
  • La QoS de los mensajes le pertenece al hub, no al módulo de datos. Los sobres, los reintentos y el empaquetado son mecánica del hub.

QoS de mensajes — exactamente tres niveles

Un módulo declara solo la garantía de entrega que necesita:

NivelSignificado
at least oncese reentrega hasta que se confirma; el receptor tolera duplicados
at most oncese envía una vez, nunca se reintenta; la pérdida es aceptable
exactly oncededuplicado y confirmado; el caro, usado donde se requiere

Esa Declaration es toda la conversación sobre la entrega. Cómo se cumple la garantía no es asunto del módulo, y no es asunto tuyo.

DI, montaje y módulos deshabilitados

El hub instancia los módulos y los monta — en la raíz o en un espacio de nombres — resolviendo las dependencias de cada módulo sobre los Primitives y sobre otros módulos. Un build que no necesita un módulo no lo monta. El montaje tiene espacios de nombres, y un segundo módulo que reclame un punto de montaje ya ocupado se rechaza en tiempo de montaje — la composición falla ahí, nunca en la primera llamada hacia él.

Como los módulos forman un grafo, apagar uno tiene consecuencias río abajo, y el hub toma exactamente uno de dos caminos:

  1. Deshabilitar la cadena dependiente. Todo módulo que necesite al que falta también se apaga, y sus interfaces están ausentes en vez de fallar.
  2. Declarar funcionalidad degradada. Los dependientes siguen montados y anuncian qué ya no pueden hacer.

No hay un tercer camino. Funcionar a medias en silencio — un módulo montado descartando calladamente las operaciones que ya no puede realizar — es el modo de fallo que esta regla existe para prevenir, y es por lo que una dependencia deshabilitada es observable en vez de misteriosa.

Por qué no te vas a topar con nada de esto

Cada promesa de las páginas de módulo se cumple por encima de esta línea: mutar una Entity es la operación de red, un Hook es una función tipada, una entrada es una llamada. Los nombres de la pila pueden llegarte — Core Concepts apunta aquí — pero la promesa es que nunca llames a nada de esto, no que las palabras sean secretas. Las capas de abajo existen para que esas promesas sobrevivan a un cambio de transporte, y una página que nunca tienes que leer es la medida de que eso funciona.

Con lo que sí te vas a topar — el contexto de entrega sobre el que corren tus manejadores, cuándo termina un handle, y la implementación en memoria contra la que pruebas — está una página más arriba: Threads, Lifetime and Testing.

Start here

PlayServ SDK

Le backend de jeu qui est livré avec le gameplay. PlayServ est un backend-as-a-service pour les jeux live : un studio fait tourner le backend de son jeu — données, joueurs, Rooms, matchmaking, commerce — sans en héberger un. Le SDK, c'est la façon dont votre code, sur le serveur et dans le moteur, travaille avec cette plateforme.

Cette page est la courte liste de ce qui est réellement différent ici. Tout ce qui suit se décide une fois pour un projet et se configure au lieu de s'écrire ; ce que vous appelez vit sur les pages de module, et chaque section d'ici finit en nommant celle qui en est propriétaire.

One declaration you write, and the five things that happen with no further code from you

La simulation n'est pas votre code

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Les Rooms, la collision, la locomotion, la prédiction et la synchronisation s'exécutent toutes dans la plateforme. Votre jeu, ce sont des Declarations (Entities, cartes, abilities, tables de drop, politique de synchronisation), des Hooks (vos règles, appelées à des étapes nommées), des Events (abonnez-vous, ne faites pas de polling) et des Operations (ce que vous demandez ou commandez). Chaque page de module est organisée autour de ces quatre-là exactement.

Ce que vous vous épargnez est concret — une boucle de jeu, l'assemblage des snapshots, un encodeur de Delta, la résolution des collisions, l'intégration du mouvement, la gestion des reconnexions, la validation des tirs avec compensation de lag. Voir Getting Started, qui construit exactement cela.

Muter un état déclaré est l'appel réseau

Il n'y a pas d'envoi. Vous déclarez comment un champ se synchronise — un attribut à côté du champ — et le modifier est l'opération réseau : des Deltas par rapport au dernier état acquitté, l'aspect comme unité de politique, la priorité et la cadence d'envoi, la fenêtre retenue, les Hooks d'avant et d'après changement. Rien en aval n'est écrit par vous.

Le même geste vaut pour tout le reste de ce qui est déclaré : un Event, un RPC, un Group, un axe de Leaderboard. Une Declaration est l'entrée de l'API typée, du panneau d'administration qui la rend, et de la codegen pour chaque binding — ce qui explique aussi que ce soit la Declaration, et non le code généré, que vous versionnez. Voir Data & Subscriptions et Schema as Code.

Les interfaces suivent l'Actor, pas le côté

Il n'y a pas de SDK client ni de SDK serveur. Un seul SDK est livré, et ce qu'un appel a le droit de faire est décidé par l'Actor qui se tient derrière — un joueur, un service, un cerveau de bot, un opérateur.

Le cas pour lequel il est conçu est la machine d'un joueur qui crée une Room puis la fait tourner : un master-client, qui tient les interfaces room-owner et rien de plus. Un build room-visitor n'a ni expulsion ni fermeture — pas désactivées, absentes.

Les droits sont composés de permissions atomiques, il n'y a donc pas de paliers de rôles intégrés, et un rôle filtre les données jusqu'à la ligne et à la colonne. Voir Autorité, et Access & Roles pour la façon dont une habilitation s'écrit.

Une seule conception, restreinte deux fois — sur une seule pile

Comment elle est écrite

Le SDK est une seule conception avec deux échappements de restriction, et l'ordre est la règle : rien ne descend d'un niveau tant que le niveau au-dessus peut réellement le porter. Les principes communs sont identiques dans chaque binding. La forme d'un langage ne prend que ce que son paradigme ne peut pas exprimer de la manière commune — C# a des attributs, Python a des décorateurs, la même Declaration écrite comme chaque langage écrit déjà cette idée. La forme d'un moteur ne prend que ce qu'un moteur remodèle par-dessus son langage : dans Unreal, une Declaration voyage à l'intérieur de la macro de réflexion du moteur, et le C# d'Unity n'est pas non plus le C# serveur.

Comment elle s'exécute

Le code de votre jeu s'adresse à des modules et à rien d'autre. Les modules sont assemblés à partir de quatre Primitives — Events, RPC, Data & Subscriptions, Groups. En dessous d'eux se tient le hub que vous n'appelez jamais : injection de dépendances, montage des modules, session, récupération d'état, qualité de service des messages. En dessous encore, les adaptateurs de transport, un par protocole, et lequel porte un appel n'est pas quelque chose que votre code décide ni remarque.

Les deux moitiés en entier : Comment le SDK est construit, qui finit sur Sous le capot.

Les modules se composent ; rien ne dérive

Il n'y a pas de module de base dont hériter ni de hiérarchie à étendre — les modules forment un graphe, parce qu'un arbre n'autorise que des branches et que les vraies fonctionnalités les traversent : le matchmaking réserve des sièges dans les Rooms, les drops placent des objets à travers la carte, un chat vit à l'intérieur d'une Room.

« Héritage » recouvre ici quatre mécanismes différents, et il vaut la peine de les distinguer : Les RPC d'une Entity font partie de l'Entity et n'existent nulle part ailleurs. Un preset est un paquet nommé d'aspects, pas une classe de base. Redéfinir une étape de la plateforme est un attribut posé sur votre remplacement. Un module en emprunte un autre par un décorateur qui restreint l'interface empruntée. Voir Héritage et composition.

Qui voit quoi est déclaré, non filtré sur le client

À quarante joueurs, un snapshot de Room entière convient ; à deux cents, non, et le remède n'est pas un tuyau plus gros. Des règles d'intérêt décident qui reçoit quelle tranche, et les paquets par Actor et la diffusion sont deux modes de livraison d'un seul modèle déclaré — passer de l'un à l'autre est de la configuration, pas une réécriture. Les choses lointaines se dégradent à travers des paliers de détail déclarés avant de disparaître.

La part qui est une propriété de sécurité plutôt que de bande passante : un état qui ne doit pas fuiter n'est jamais envoyé. Le brouillard de guerre et les champs réservés au propriétaire sont absents du paquet, pas cachés sur le client. Les spectateurs, les administrateurs et les replays obtiennent leur vue plus large en tenant une habilitation plus large — encore de l'autorité, pas un cas particulier. Voir Visibility.

Ce qui survit à la perte d'un hôte

La mort d'un host ne met pas fin au match. L'état de la Room n'est pas copié entre les hosts pendant le jeu — un propriétaire unique est ce qui garde l'ordonnancement hors du consensus — et ce qui rend le match survivable, c'est que l'état est déclaré, et qu'un état déclaré est conservé hors du host. Il est capturé à un intervalle déclaré, et un remplaçant repart du dernier instantané.

Le remplaçant a donc l'état entier — mais tel qu'il était à cet instantané. Complet, pas actuel. Ce que cela coûte, c'est le jeu depuis le dernier instantané ; ce qui n'est pas couvert, c'est tout ce que vous n'avez gardé que dans des acteurs du moteur. Un déploiement utilise le même mécanisme, sans la perte : drainer un host est le chemin de bascule exécuté délibérément. Voir What Survives Losing a Host, et Rooms pour la fenêtre de grâce par laquelle un joueur revient.

N'importe quelle étape de la plateforme peut être la vôtre

Chaque scénario de la plateforme est une chaîne de fonctions enregistrées, et vous remplacez un maillon ou vous l'enveloppez. Connexion, validation d'entrée, achat, soumission, upload — chacune est une étape nommée, et votre remplacement est déclaré par un attribut, avec des versions choisies par condition et l'étape propre à la plateforme en repli.

Voilà ce que « plateforme personnalisable » veut dire concrètement, et c'est ce qui tient lieu de vous livrer notre source : vous remplacez les étapes plutôt que de forker ce qui les exécute. Voir Extensibility.

Une seule surface, six langages

Un contrat, six projections : C#, TypeScript, Python et Go côté serveur ; du C++ Unreal généré et du C# Unity dans le moteur. Chaque exemple de code de ce site montre les six, et là où un binding n'a pas de surface pour une étape, l'onglet nomme la raison au lieu de faire semblant — l'étape s'exécute hors du moteur, ou un autre Actor détient le droit de faire cet appel.

Deux conséquences à connaître avant de choisir un langage : le RPC prend les objets du SDK par référence plutôt que des DTO aplatis, et les primitives asynchrones sont au cœur plutôt que rajoutées — Channels, Streams et adressage de Group, de sorte que vous pouvez parler à un Group entier et collecter les réponses, ou consommer un fichier par morceaux pendant qu'il est encore en cours d'upload.

Il y a aussi un hôte en mémoire déterministe qui exécute le code de votre jeu sans aucun backend derrière et avec le temps sous votre contrôle, de sorte qu'un test est un test et non une race condition — voir Threads, durée de vie et tests.


Ce qui n'est délibérément pas dans le SDK — déploiements, facturation, administration de l'organisation et des utilisateurs — vit sur le plan opérateur. La barre latérale est la carte de tout le reste ; Getting Started est le chemin d'entrée le plus court.

Start here

Pour commencer

Une arène jouable (carte, tanks, tir, drops), déclarée de bout en bout. Rien de ce qui suit n'est une boucle de jeu : la simulation s'exécute dans la plateforme, et voici tout le code qu'il y a.

Avant de commencer. Un Project avec un Environment dev (créé sur le plan opérateur, qui possède ce cycle de vie), la CLI playserv connectée dessus, et le paquet SDK de votre binding — rien d'autre n'est installé dans votre jeu.

Parcours utilisateur

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

Chaque appel que vous faites est l'un des exemples ci-dessous ; les étapes entre eux sont la plateforme agissant sur ce qu'une Declaration a dit. L'ability, le Stat et la table de drop de la figure sont des entity presets — des Declarations posées sur des Entities, pas des modules à part entière.

1. Déclarer le monde

Les Entities sont votre schéma plus leurs aspects vivants. Un attribut par comportement, à côté du champ qu'il décrit :

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

Muter un champ [Sync] est l'opération réseau. Il n'y a pas de snapshot à assembler ni d'appel d'envoi à faire.

Aucun de ces types n'est à vous de définir, et chacun appartient à une page :

Dans le blocVient de
Vector3, Statle paquet core de votre binding
Body, et les formes de corpsCollision
Motion, et les cinq modèles de déplacementLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectles entity presets qui les utilisent
EntryRequest, Verdict, StatEventdes charges utiles de Hook, remises par le module que vous accrochez
SeatMatchmaking
Scope, les portées de synchronisationVisibility
Tick, les cadences de TickRooms

Les enums sont fermés. Une règle qu'aucun membre ne couvre s'écrit comme un prédicat plutôt que comme un nouveau membre : [Aspect("loadout", Visible = "owner == caller.player")] est la façon d'exprimer une visibilité par champ quand Scope.Owner n'est pas tout à fait la règle que vous vouliez (Data).

2. Déclarer la Room

Un template de Room dit ce qu'est une session, et nomme les Declarations sur lesquelles il s'appuie. Il n'y a pas de classe de Room dont hériter ni de méthode de Tick à remplir, parce que l'intérieur d'une Room appartient à la plateforme :

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

Les ids entre guillemets sont des clés de contenu, pas du texte libre :

CléCe qu'elle nomme
rockun prop dans l'obstacle set de la carte (Map)
ammo.shell, railgundes articles du catalogue (Catalog & Commerce) — qui est aussi la façon dont le tir débite les munitions et dont le ramassage atterrit dans un sac
battle, arena, crate-loot, shellles clés que ces quatre Declarations enregistrent

playserv push refuse une Declaration dont la clé n'existe pas dans l'Environment qu'elle vise, si bien qu'une clé mal tapée échoue au déploiement plutôt qu'au premier cast. Où que vive le template, il reste réajustable sans nouveau déploiement du moteur : le modèle poussé est ce que le live-ops édite dans le panneau.

3. Écrire vos règles comme des Hooks

Les Hooks sont des fonctions cloud que la plateforme appelle à des étapes nommées. Typées en entrée, typées en sortie — pas de sacs de contexte, pas de loggers dans la signature :

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Une barrière Before peut refuser l'étape ; un observateur After s'exécute une fois qu'elle a été committée et ne le peut pas. Ainsi un pack de démarrage qui n'a pas abouti coûte 20 obus, pas la connexion. Extensibility contient le reste.

Presque rien dans ce bloc ne fait le travail qu'il semble faire :

La ligneCe qui l'accomplit réellement
Stats.Depleted se déclenchele Stat atteignant son plancher — 0 pour Hp, puisque la Declaration n'a fixé que Max
la transition de mortAtMin = "death" en section 1 ; le Hook ajoute la conséquence, pas la transition
les dégâts[Effect(Damage = 35)] sur l'obus, appliqué par la plateforme lors d'un impact
RollAtgénéré sur la Declaration [DropTable] — c'est pourquoi elle est partial, et pourquoi l'onglet Go se lit drops.RollAtCrateLoot
le ramassagerouler sur du loot transfère les objets dans l'inventory du joueur de façon atomique ; ce transfert est l'Event changed qu'un HUD affiche

Types générés pour le moteur

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

Exécutez-la (ou laissez la CI l'exécuter) après chaque push de schéma — les types sont régénérés, jamais édités à la main, et le Tank généré est le Tank poussé. playserv push lit un projet de moteur exactement comme il lit un projet serveur : les spécificateurs UHT et les attributs C# sont la Declaration, donc pointer la CLI sur le projet UE ou Unity constitue toute l'étape d'export. Sur quel thread atterrit un callback, et quand un abonnement prend fin, sont fixés par le modèle d'exécution — Threads, durée de vie et tests.

4. Connecter un client

L'API cliente est symétrique : les mêmes modules, et ce qu'un build a le droit d'appeler est décidé par la clé sous laquelle il s'exécute. Un build de moteur porte une clé joueur — projectKey ici, la credential pour un Project et un Environment, émise dans le panneau et livrée à l'intérieur du build. Elle ne nomme aucun rôle : les rôles se résolvent côté serveur à chaque requête, et le joueur derrière eux arrive avec SignIn. Les bindings de moteur sont de première classe ici ; les bindings serveur pilotent la même surface en headless (un cerveau de bot, un test de charge, un outil d'ops) :

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

Quatre appels, et chacun répond quelque chose de différent :

AppelCe qu'il renvoie
Findun ticket placé, et un siège réservé dans une Room. La réservation est tenue pour la durée que déclare le template ; la laisser expirer coûte le siège, pas le droit de jouer
Joinse résout une fois que l'état courant de la Room est arrivé. Le Tank que le template fait apparaître pour un membre qui entre fait partie de cet état, si bien que My<Tank>() répond à la ligne suivante et que tout ce qui suit Join est du trafic en direct
Casttirer est le verbe de la capacité, pas une seconde surface : une Declaration [Projectile] est une capacité avec de la balistique par-dessus, si bien que Cast vérifie le temps de recharge, les munitions et la cible comme il le ferait pour un dash ou un soin (Entity Presets)
Abilitiesgénéré — le codegen rassemble les capacités et les projectiles déclarés en un type par binding

Les refus arrivent sous la forme du Problem typé de la plateforme — un code plus une raison pour un humain. C#, TypeScript, Python et Unreal le lèvent ; Go le renvoie comme valeur d'erreur, ce qui explique que chaque appel de cet onglet soit vérifié. Un serveur dédié Unreal ou un master-client exécute le même binaire sous une clé d'hôte à la place, et ses rôles portent les lignes mc : Rooms → Héberger une Room est cette surface de bout en bout, Access est là où les clés et les rôles derrière elle sont déclarés.

5. Pousser et jouer

playserv push          # schema + declarations + hooks, one deploy
playserv open battle   # a dev-env room, live in the panel

playserv push balaie le projet dans lequel il s'exécute — Hooks par attribut et Declarations — et déploie vers l'Environment que vous visez (--env dev par défaut). playserv schema push seul ne déplace que le modèle, et playserv schema diff est ce contre quoi un push est comparé (Schema).

Un push atterrit en entier ou pas du tout, et il est refusé plutôt que fusionné si le schéma déployé a bougé depuis votre diff. Un changement qui casserait des données existantes ne voyage pas du tout avec le push : il devient une migration que vous lisez d'abord, puis exécutez ou annulez (Schema). Déplacer un modèle de dev vers prod est un acte du plan opérateur plutôt qu'un appel du SDK (le plan opérateur).

playserv open battle crée une Room à partir du template battle poussé et l'ouvre dans le panneau, où l'état de la Room et ses membres sont inspectables pendant que vous jouez contre elle. Le panneau montre désormais le template, la carte, la table de drop et les Hooks : le même modèle que vous avez écrit en code, éditable là aussi.

Les nombres que vous n'avez pas choisis

Capacity = 8, Hz30, Hz = 10 et Cooldown = 1.5f sont le réglage de ce jeu, pas des plafonds. Les limites propres à la plateforme se tiennent au-dessus d'eux, et chacune est déclarée avec ce que l'appelant observe au bord :

Au bordCe que reçoit l'appelant
une entrée au-delà de la capacité, ou dans une Room ferméeconflict — à retenter quand un siège se libère
une création de Room au-delà de la limite par Project ou par Actorrefusée, et rien de déjà créé n'est détruit
créer des Rooms ou se connecter trop viteun refus de limite de débit portant le temps d'attente
une charge utile d'Event au-delà du plafond de la Roomrefusée avant l'envoi, jamais tronquée
une lecture au-delà du plafond de lignes d'un rôlela valeur du plafond en lignes, plus le marqueur disant qu'elle a été coupée

Les nombres eux-mêmes sont par Environment et arrivent avec les limites de la plateforme ; le comportement au bord ne les attend pas (Rooms, Access, Auth).

Où aller ensuite

  1. Apprendre par l'exemple, la section juste après celle-ci : un Leaderboard dans Tanks, des caisses de soin dans Tanks, ou la recette du tournoi quotidien pour la boucle méta — une vraie fonctionnalité chacune, chaque étape renvoyant à la page de module qui possède ce que vous venez d'utiliser.
  2. Comment le SDK fonctionne, quand sa forme commence à compter plus que la fonctionnalité suivante : Notions essentielles est le dictionnaire, et quatre articles répondent à qui appelle (Autorité), comment s'écrit une habilitation (Access & Roles), de quoi le SDK est fait (Comment le SDK est construit) et comment il s'exécute (Threads, durée de vie et tests).
  3. Puis les modules. Chaque page de module a la même anatomie — thèse, Actors, quand l'utiliser, parcours utilisateur, exemples, modèle — si bien que la deuxième se lit plus vite que la première et que la cinquième prend quelques minutes. Entity et Data sont les deux sur lesquelles tout le reste s'appuie.

Parcours de lecture par rôle

Quel que soit votre rôle, lisez d'abord Autorité — un seul SDK et une habilitation par Actor est le prérequis commun — avec Notions essentielles ouvert à côté.

Vous êtesLisez, dans l'ordre
Dev client de jeu (Unity · client Unreal · TS)Auth → Matchmaking → Rooms → Entity → Data, puis par fonctionnalité : Inventory · Leaderboards · Messaging · Profile
Dev serveur (C# · TS · Python · Go)Schema → les briques de base → Entity → Extensibility → Access, puis les modules dont vous possédez les Declarations : Rooms · Matchmaking · Leaderboards · Commerce
Dev serveur dédié UnrealRooms (Héberger une Room) → Bots → Locomotion · World Objects → Map → Ce qui survit à la perte d'un hôte
Examples

Un Leaderboard dans Tanks

Tanks, l'arène d'exemple de Getting Started, n'a pas de Leaderboard. Cette leçon, que vous pouvez suivre à tout moment après Getting Started, ajoute un tableau hebdomadaire des kills en trois étapes : déclarer le tableau, soumettre depuis le Hook de kill, le lire dans le client. Chaque étape renvoie à la page de module qui possède ce que vous venez d'utiliser, si bien que la leçon enseigne en pointant plutôt qu'en répétant.

Étape 1 — déclarer le tableau

Un tableau est une Declaration : quel champ le classe, comment des soumissions répétées se combinent, quand il se réinitialise, et qui a le droit de soumettre. Aggregation.Increment ajoute chaque soumission au total courant, donc un kill vaut un point. Submit.ServerOnly est la valeur par défaut et ferme le tableau aux clients, ce qui fait de l'étape 2 la seule voie d'entrée.

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

Poussez-la avec playserv push et le tableau apparaît dans le panneau, vide, avec son cycle du lundi déjà planifié — lundi 00:00 UTC, puisque les plannings sont en UTC. Les axes que vous n'avez pas réglés gardent leurs valeurs par défaut. Voir Leaderboards pour la liste complète des axes — propriétaire, clé d'ordre, champs d'affichage, règles de tournoi.

Étape 2 — soumettre depuis le Hook de kill

Tanks met déjà fin à une vie par le seuil de HP déclaré sur le tank : à zéro HP, la transition death se déclenche et la plateforme appelle le Hook après elle. Le Hook est une fonction cloud, typée en entrée et typée en sortie, si bien que soumettre un kill tient en une ligne à l'intérieur.

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By est l'attaquant que portaient les dégâts, aucune comptabilité ne suit donc qui a tiré sur qui. e.Id est l'id propre de l'Event, et le passer comme clé d'idempotence est ce dont un tableau Increment a besoin : un Event de kill re-livré compte une fois, pas deux. Le point d'accroche, les garanties d'ordre et le contrat de veto relèvent d'Extensibility ; le seuil qui le déclenche est un stat preset sur le tank, et la ligne de score qu'il écrit est de la data ordinaire que vous pouvez interroger.

Étape 3 — lire le tableau dans le client

Deux lectures couvrent toute l'interface : le haut du tableau et la fenêtre autour du joueur local — cinq lignes au-dessus, cinq en dessous, plus la vôtre. Les deux reviennent sous forme d'entrées classées avec les kills et le nom affiché, prêtes à être liées à une liste. Un abonnement garde le panneau à jour pendant que la partie se joue, et il ne livre que le rang du joueur local.

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

La réinitialisation du lundi clôt le cycle au lieu de le supprimer, si bien que le tableau de la semaine dernière reste lisible par son étiquette — le même appel Top avec un argument cycle:. Un Hook de récompense à la clôture du cycle est la quatrième étape naturelle, décrite sur Leaderboards.

Où aller ensuite

Examples

Des caisses de soin dans Tanks

Avant 
un tank qui prend des dégâts reste endommagé jusqu'à sa mort. Il n'y a pas de retour en arrière, chaque combat est donc un compte à rebours et l'arène n'a aucune raison d'être parcourue.
Après 
des caisses de soin apparaissent autour de l'arène, espacées les unes des autres et à l'écart de ceux qui se battent. Rouler sur l'une d'elles vous soigne. Rien d'autre ne change dans le jeu — et le code de la Room non plus, puisqu'il n'y en a pas.

C'est la deuxième leçon Tanks. Elle prend trois étapes et aucun nouveau module : une Declaration pour la caisse, une Declaration pour l'endroit où les caisses apparaissent, et un Hook pour ce que fait le ramassage. Suivez-la après Getting Started, dans l'ordre que vous voulez avec la leçon sur le Leaderboard.

Étape 1 — déclarer la caisse

Une caisse est une Entity avec deux presets appliqués et un corps qui signale le contact sans arrêter personne. Response.Pass sur la couche pickups est ce qui en fait un ramassage plutôt qu'un obstacle : le contact est signalé, le mouvement passe tout droit.

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

Deux choses que vous n'avez pas eu à écrire : où la caisse est dessinée (le client rend déjà les objets de monde déclarés) et comment sa position atteint les clients — [Sync] est l'appel réseau.

Possédé par Entity Presets et Collision.

Étape 2 — déclarer où les caisses apparaissent

Le placement est lui aussi une Declaration, et c'est l'étape qui décide si la fonctionnalité paraît juste. L'espacement empêche les caisses de s'agglutiner, la distance aux joueurs les empêche d'apparaître au milieu d'un duel, et la règle de non-répétition empêche le même endroit d'être la réponse à chaque fois.

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

Les positions valides viennent de Map — la table demande un emplacement sur la couche ground et la carte en renvoie un qui est réellement atteignable, si bien qu'une caisse n'atterrit jamais dans un mur.

Possédé par Entity Presets et Map.

Étape 3 — soigner au ramassage

Un seul Hook, et c'est le seul code de la leçon. Il s'exécute sur la plateforme comme fonction cloud, ce qui explique qu'il n'apparaisse sur aucun des deux onglets de moteur.

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

Trois choses que ce Hook obtient gratuitement, et chacune explique que la leçon soit si courte :

  • Le refus est typé. Un tank en pleine santé reçoit already at full health avec une raison qu'un client peut afficher, et la caisse est encore là pour quelqu'un qui en a besoin.
  • Ajuster un Stat fait autorité côté serveur. Ce n'est pas quelque chose qu'un client peut demander, il n'y a donc aucune exception anti-triche à écrire pour les ramassages.
  • Le HUD se met à jour sans qu'on le lui dise. Adjust émet changed ; le client est déjà abonné aux Stats déclarés du tank. Vous n'avez écrit aucun message réseau.

Possédé par Extensibility et Entity Presets.

Ce qui a changé, et ce qui n'a pas changé

AvantAprès
un tank endommagéreste endommagé jusqu'à sa mortpeut récupérer en parcourant l'arène
code de Roomaucuntoujours aucun
nouveaux modules montés—aucun : deux Declarations et un Hook
exceptions anti-triche—aucune : le soin fait autorité côté serveur comme tout changement de Stat

Où aller ensuite

  • Entity Presets — le générateur de drops, les objets de monde et le modèle de Stat sur lesquels cette leçon s'est appuyée, tous trois des presets d'entity plutôt que des modules.
  • Collision — les couches, les réponses, et la différence entre un contact signalé et un contact bloquant.
  • Map — comment une position valide est choisie, et ce que « atteignable » veut dire.
  • Extensibility — chaque point d'accroche dans l'ordre, avec le contrat de veto.
  • Un Leaderboard dans Tanks — l'autre exemple Tanks.
Examples

Un tournoi quotidien

Ce que vous obtenez : un tournoi quotidien avec une fenêtre d'inscription, des Rooms amorcées et un versement de prix — construit entièrement à partir de Declarations et de Hooks sur des modules dont vous avez déjà les pages. Rien ici n'est un concept nouveau ; ce sont Leaderboards, Matchmaking, Rooms, Commerce et Messaging composés pour une seule boucle méta.

Étape 1 — déclarer le tableau avec une fenêtre d'inscription et des limites de tentatives

Un tournoi est une Declaration de Leaderboard ordinaire plus des contraintes de participation : une fenêtre d'inscription, un plafond d'inscrits et de tentatives par cycle. Rien ne change côté score — la clé d'ordre, l'agrégation et la réinitialisation restent exactement comme sur n'importe quel tableau.

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

Poussez-la et le panneau montre un tableau vide avec sa fenêtre planifiée. joinRequired: true fait des inscrits une appartenance plutôt que tous ceux qui jouent, si bien qu'une soumission venant d'un non-inscrit est refusée. Voir Leaderboards pour le reste de la liste des axes et pour ce que fait chaque contrainte à son bord.

Étape 2 — la fenêtre s'ouvre : un groupe entre, des Rooms sont amorcées

Une fois la fenêtre d'inscription ouverte, les joueurs font la queue exactement comme pour n'importe quelle partie : créer ou rejoindre un groupe, puis un seul appel Find. Le matchmaker place le groupe dans un tableau de tournoi et Rooms amorce la partie — le même chemin de placement et de siège que toute partie utilise, simplement borné à la file du tournoi.

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

Les plafonds de l'étape 1 appartiennent au tableau, pas au matchmaker : la file place les groupes, et c'est le tableau qui rencontre un inscrit au-delà de son plafond ou un joueur au-delà de ses tentatives. Le 65ᵉ inscrit sur 64 est refusé comme conflit sans que rien ne soit évincé, et une quatrième soumission dans un même cycle répond « tentatives épuisées » — également un conflit, levé par la réinitialisation quotidienne plutôt qu'en demandant une permission.

Étape 3 — les scores se soumettent via le Hook on-dispose

Les Rooms ne rapportent pas d'elles-mêmes un vainqueur à un Leaderboard ; ce lien est un Hook, sous le même contrat Extensibility que partout ailleurs — typé en entrée, typé en sortie, pas de sac de contexte. Le Hook on dispose de la Room (Rooms) est la dernière chose qui s'exécute avec l'état final de la partie en main, et c'est de là qu'il soumet.

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner et FinalScore sont des champs que le template de Room propre à ce jeu déclare dans son état — la plateforme n'ajoute rien au snapshot (Rooms est là où l'état d'un template est déclaré). Le Hook de dispose (Rooms.Disposed) remet le snapshot final, si bien que la partie n'est jamais recalculée. Le Hook de pré-soumission de Leaderboards s'exécute toujours en premier — un score de tournoi est soumis au même contrat corriger-ou-rejeter que n'importe quelle autre soumission.

Étape 4 — le cycle se ferme : récompenses accordées, joueur notifié

La réinitialisation quotidienne de l'étape 1 clôt le cycle exactement comme le fait toute réinitialisation de Leaderboard, et déclenche CycleClosed portant l'étiquette du cycle clos — c'est cette étiquette qui fait lire au Hook le tableau qui vient de se fermer plutôt que celui, vide, qui vient de s'ouvrir.

Un Hook fait le reste : il accorde le prix par le chemin des entitlements de Commerce et pousse le résultat par Messaging, si bien qu'il n'y a aucun travail de versement séparé à lancer. Une notification est adressée à un Actor, si bien que le top 8 est une boucle de huit, chacune portant son propre argument rank dans le modèle.

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

Les nombres de l'étape 1 sont des valeurs seed : le LiveOps réajuste la fenêtre, le plafond d'inscrits et le nombre de tentatives dans le panneau, et le déploiement suivant n'écrase pas silencieusement la modification. Faire de ceci un tournoi hebdomadaire tient en une seule retouche — Reset.Daily() devient Reset.Weekly(DayOfWeek.Monday), et les étapes 2 à 4 restent telles quelles.

Où aller ensuite

How the SDK works

Notions essentielles

Les mots que le reste de ces pages emploie sans s'arrêter pour les expliquer. Une page de module suppose que vous savez déjà ce qu'est un Actor, un aspect ou une Room — ici chacun reçoit une définition d'une ligne et un lien vers la page où vit réellement le mécanisme qui le porte. Lisez-la une fois avant la référence des modules, ou revenez-y quand un mot se révèle porter plus de poids que prévu.

Trois choses sont trop grandes pour une entrée et ont chacune une page : Autorité — qui appelle, et ce que cela seul décide ; Comment le SDK est construit — de quoi le SDK est fait ; Héritage et composition — comment les modules s'appuient les uns sur les autres. Dans cet ordre, elles se lisent comme un seul raisonnement.

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

Les quatre surfaces

Chaque module expose exactement quatre choses, et chaque page de module est organisée autour d'elles. Voici le modèle de programmation :

SurfaceSens
Declarationsce qui existe et comment cela se comporte, écrit en code ou dans le panneau d'administration ; le même modèle dans les deux cas
Hooksvos règles, appelées par la plateforme à des étapes nommées ; déployées comme fonctions cloud
Eventsce que la plateforme vous dit qu'il s'est passé — abonnez-vous, ne faites pas de polling
Operationsce que vous demandez ou commandez, depuis une fonction ou un client

Actor

Qui fait un appel. Ce qu'un appel a le droit de faire est décidé par l'Actor derrière lui, jamais par le build dans lequel le code a été compilé — le raisonnement est Autorité, le mécanisme (permissions atomiques, rôles composés, InterfaceGrant) est Access & Roles.

Cette documentation tire les noms d'Actor d'un seul catalogue — les presets que la plateforme livre. C'est un ensemble de presets, pas une liste fermée (un Project nomme ses propres Actors), mais chaque ligne actors d'un schéma, chaque ligne « Qui fait quoi » et chaque pastille de diagramme de flux dans ces pages emploie exactement ces graphies :

player · backend-service · operator · host · moderator · schema-author · architect · bot-brain · room-owner · room-visitor · entry-validator · spectator · match-organizer · warehouse-keeper · seller — et any quand une page les désigne tous.

Une page peut en outre introduire un rôle de scène pour un seul diagramme — un participant descriptif comme member ou attacker — tant que sa propre prose ou son tableau « Qui fait quoi » l'introduit d'abord.

Runtime surface

Où le code s'exécute. Ces quatre tags servent dans chaque tableau d'Operations :

TagSurface
fnFonction cloud (C# · TypeScript · Python · Go). Fait autorité côté serveur ; l'endroit principal où vivent vos règles
clClient de jeu (Unreal C++ / Unity C#). API symétrique ; les rôles débloquent moins
mcLa surface d'hôte de Room : un master-client (un client qui possède une Room) ou un serveur dédié Unreal sous sa clé d'hôte
admPanneau d'administration / CLI / MCP — là où le SDK et le plan opérateur partagent un modèle

C'est l'axe qu'on confond sans cesse avec celui du dessus. Où le code s'exécute et quelle interface d'Actor il détient sont deux questions distinctes : le même code tient les mêmes droits où qu'il soit placé, et seule l'habilitation diffère.

Project & Environment

Un Project est le backend d'un seul jeu, avec un schéma et ses données dans des Environments isolés (dev, prod). Chaque appel s'exécute dans un Project + un Environment.

Entity

Le nom central. Une Entity est une déclaration de schéma plus ses aspects vivants : data 0..*, états 0..*, RPC 0..*, Events 0..*, Hooks, et historique des changements. Un tank, une porte, une barre de Stat et une quête sont tous des Entities, ne différant que par les aspects qu'ils portent. Les combinaisons courantes sont livrées comme presets (GameObject, Stat, Character, Interactable, Projectile). Voir Entity.

Expected state

Une demande de transition peut nommer l'état qu'elle attend, et c'est alors cet état ou un refus. Une demande qui n'en nomme aucun est évaluée contre l'état que tient la machine au moment où la plateforme la traite — jamais contre l'état au moment de l'envoi.

La réponse décrit cet instant et ne promet rien sur la suite : la transition de quelqu'un d'autre qui atterrit pendant que la réponse est en vol laisse la réponse vraie et ne l'annule pas. Nommez donc l'état attendu quand l'issue dépend de ce qui était là avant, et sinon ne lisez pas la réponse comme un snapshot qui survit à l'appel.

Room

Une Room est une session de jeu, pas un endroit où votre code s'exécute. La plateforme se moque de ce qui l'héberge : un serveur dédié, un master-client, ou le backend lui-même. L'intérieur d'une Room est le nôtre ; vous conduisez une Room de l'extérieur, depuis des fonctions cloud et des clients, à travers des Declarations, des Hooks, des Events et des Operations. Voir Rooms.

Channel & Stream

Les primitives asynchrones sous tout le reste. Un Channel est un sujet pub/sub adressable : une Room, un Group, une Entity, ou le vôtre. Un Stream est un flux découpé en morceaux dans les deux sens : les fichiers sont consommés à mesure que les morceaux arrivent, les requêtes peuvent être diffusées en flux, et un RPC peut partir en fan-out vers un Group et collecter les réponses. Voir Core.

Primitive

L'une des quatre briques dont chaque module est assemblé : les Events (déclarer, émettre, s'abonner), le RPC (invoquer sur le réseau), les data et abonnements (la mécanique de synchronisation) et les Groups (une liste, plusieurs auditeurs). Une Room, un chat et un pool de matchmaking sont la même Primitive de Group sous des règles différentes. Si une fonctionnalité ne peut pas s'exprimer à travers les quatre, c'est un défaut de conception et non un argument pour une cinquième.

Contrat de Hook

Un seul contrat partout : un Hook before s'exécute avant la validation, reçoit la charge utile typée, peut la muter ou refuser ; un Hook after s'exécute une fois l'opération committée, reçoit la requête et le résultat, et ne peut qu'ajouter des effets de bord — il ne peut jamais faire échouer l'opération. Les Hooks sont ordonnés ; chaque étape enregistrée de la plateforme peut en porter. Voir Extensibility.

Delta & Revision

Les clients reçoivent l'état sous forme de Deltas : seuls les champs modifiés, encodés par rapport au dernier état que le destinataire a accusé. Chaque enregistrement porte une Revision ; les écritures conditionnelles rejettent si la Revision ne correspond pas. Un seul concept de versionnement sert la synchronisation, la concurrence et l'historique. Voir Data. (Ce qu'Unreal appelle replication — quel client voit quel état, à quelle fréquence — vit ici ainsi que dans Visibility et Prediction. La page Ce qui survit à la perte d'un hôte est l'autre : quelle machine possède une Entity, et laquelle la possédera ensuite.)

Tick

Les Rooms simulent à pas fixe. Chaque changement d'état est estampillé de son Tick ; la synchronisation, la prédiction, la compensation de lag et l'historique comptent tous en Ticks plutôt qu'en temps d'horloge. Les données portent leur véritable heure d'événement — c'est ce qui rend le rembobinage et la réconciliation exacts. Voir Prediction.

How the SDK works

Autorité

L'autorité est une abstraction, pas deux builds du SDK. Il n'y a pas de SDK client ni de SDK serveur. Il y a un seul SDK, et ce qu'un appel donné a le droit de faire est décidé par l'Actor qui le fait.

Un master-client n'est ni un client ni un serveur

La machine d'un joueur qui crée une Room puis la fait tourner — un master-client — tient les interfaces room-owner et rien de plus. Ce n'est pas un serveur : elle ne peut pas tout ce qu'un serveur peut. Ce n'est pas non plus un client ordinaire.

Un serveur dédié est la même figure vue de l'autre côté : le même client sans le rendu, et il n'a besoin d'aucun SDK séparé. Ce qui sépare les deux est la confiance, pas la construction, et la confiance est portée par l'habilitation.

Les interfaces suivent l'Actor, pas le côté

Un module n'expose pas « l'API cliente » et « l'API serveur ». Il expose ce qu'un room-owner peut faire, ce qu'un entry-validator peut faire, ce qu'un seller peut faire. Le client et le serveur sont de la tuyauterie ; les Actors sont le domaine. À l'intérieur de chaque page de module, la surface est regroupée de la même façon — ceci est pour ces besoins-là, cela est pour ceux-là.

Un rôle est le droit et la classification, les deux

Il y a ici exactement une dimension. Un rôle porte ce qu'un Actor a le droit de faire, et c'est aussi la façon dont vous dites à qui quelque chose s'adresse. Nous n'avons délibérément pas ajouté à côté un second axe de tags ou d'étiquettes : une chose à déclarer, une chose à vérifier, une chose à lire dans le panneau d'administration.

whoami est la façon dont le code demande. Il rapporte l'Actor et les interfaces que cet Actor débloque à cet instant — pas une liste statique figée dans le binaire au moment du build.

Où le code s'exécute et quel Actor il tient sont deux questions distinctes

QuestionRéponses
Où ce code s'exécute-t-il ?une fonction cloud · un client de jeu · un master-client ou un hôte serveur dédié
Quelle interface d'Actor tient-il ?player · room-owner · entry-validator · seller · moderator · backend-service · …

Disposés en grille, les deux axes sont indépendants et chaque case est atteignable :

fonction cloudclient de jeuhôte de Roomadmin
player✓✓✓—
room-owner✓✓ — la machine d'un joueur, en hébergeant✓—
backend-service✓—✓✓

La case mise en évidence est du code qui tourne sur un client et fait le travail d'un serveur. Elle est nommée — room-owner — et c'est une habilitation comme une autre.

Toute combinaison est légale. Une fonction cloud n'est pas automatiquement privilégiée, et un client n'est pas automatiquement limité : les droits viennent de l'habilitation, et l'habilitation est déclarée. Les droits du code sont les mêmes où qu'il s'exécute — seul ce qui lui a été accordé diffère.

La révocation prend effet sans réémettre la credential

Une credential nomme une identité. Elle ne porte pas de liste de rôles. Les rôles se résolvent côté serveur, à chaque requête, ce qui veut dire que le client ne tient jamais la preuve de ses propres permissions et qu'il n'y a rien de périmé à continuer de présenter après une révocation.

Deux conséquences, chacune énoncée là où elle a sa place :

  • Révoquer un rôle prend effet sans réémettre la credential — voir Accorder un rôle.
  • Cela devient observable au plus tard à la borne de péremption déclarée sur le cache des droits. Nous ne promettons pas l'instantané.

Là où l'autorité est déclarée, pas inférée

  • Un type de Room déclare son mode d'autorité, et il n'y a pas de valeur par défaut : ou bien notre simulation conduit le Tick, ou bien une autorité externe le fait — le serveur de jeu du studio, ou le client d'un joueur comme master-client. C'est Qui conduit le Tick.
  • Jusqu'où une autorité externe est crue au sujet de l'issue est une Declaration séparée sur le type de Room — l'accepter, la vérifier avec un Hook, ou ne pas l'accepter. Encore une fois pas de valeur par défaut.
  • Quelle credential se résout en quel rôle, et ce qu'est une clé d'hôte, c'est Access & Roles.
How the SDK works

Access & Roles

Les rôles sont composés, jamais codés en dur. Des permissions atomiques se composent en rôles ; les rôles filtrent les données jusqu'à la ligne et à la colonne, et décident quelles interfaces de module un build voit seulement. Cela remplace le découpage de clés client/serveur : une credential nomme une identité, ses rôles se résolvent à chaque requête.

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

Quand l'utiliser

  • Vous avez besoin d'une credential plus étroite que « client » ou « serveur » — des rôles composés se résolvent derrière elle à chaque requête.
  • L'accès aux données doit s'arrêter aux lignes et aux colonnes : cadrage régional, masques de données personnelles, prestataires en lecture seule.
  • Un build ne devrait voir que les interfaces que son rôle débloque — kick/fermeture n'existent tout simplement pas pour un visiteur.
  • Votre interface doit griser les boutons honnêtement — CanI évalue la même politique que le serveur appliquera.
  • Passez votre chemin quand les presets livrés (player, room-owner, seller, …) correspondent déjà à vos Actors — chaque module les respecte par défaut ; le catalogue complet vit sur Notions essentielles.

Qui fait quoi

ActorSur cette page
operatordéclare les rôles et les politiques, fixe les limites de lignes/colonnes, accorde les rôles, émet les clés
match-organizerle personnel de tournoi du flux ci-dessous : tient une clé composée, filtre les entrées, ne peut pas rembourser
every actorvérifie CanI avant d'agir ; ne voit que les interfaces qu'il a débloquées

En un coup d'œil

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

Un atome est une paire — une ressource et l'un de quatre verbes : lire, écrire, exécuter, administrer. L'ensemble des verbes est fixe, et un cas qui n'y entre pas fait scinder la ressource plutôt qu'allonger la liste. C'est pourquoi filtrer l'entrée de quelqu'un d'autre est Rooms.Membership.Administer plutôt qu'un verbe ValidateEntry à part : agir sur l'appartenance d'un autre Actor est de l'administration, tandis qu'entrer soi-même est Rooms.Membership.Write sur la même ressource.

Le modèle

L'ACL des données est rôle × opération × prédicat de ligne × masque de champs — un seul modèle, identique qu'il ait été écrit en code, par l'API, ou dans la grille des rôles du panneau.

Ce dont l'accès est fait.

TermeCe que c'est
atomune paire ressource × verbe. Les quatre verbes sont read (récupérer, sélectionner, s'abonner), write (créer, modifier, supprimer, et agir pour soi-même — entrer, sortir), execute (invoquer une fonction, appliquer une ability) et administer (agir sur autrui : expulser, fermer, forcer un changement d'état)
roleun ensemble nommé d'atomes. Il peut en inclure un autre, et un cycle dans l'inclusion est une erreur de configuration plutôt que quelque chose à résoudre à l'exécution
role presetlivré au-dessus des atomes et continue de fonctionner inchangé pour les consommateurs déjà déployés. Un point de départ, pas une contrainte : un Project déclare ses propres rôles à partir des mêmes atomes
row predicatequelles lignes — un prédicat booléen sur les valeurs de session
field maskquels champs, déclarés par rôle et par opération. Un champ qu'un rôle n'a pas le droit de lire n'est pas renvoyé du tout, plutôt que renvoyé vide

Ce à quoi se résout une credential.

CredentialCe qu'elle débloque
player keyun build de moteur la porte ; le joueur derrière elle arrive avec la connexion, et le build voit les lignes cl de chaque tableau d'Operations
host keyun serveur dédié ou un master-client la tient, et ses rôles débloquent les lignes mc
pushed codes'exécute sous le rôle backend-service du Project — c'est ce que vérifie le Authoritative = true d'un Leaderboard
a registered hookn'accorde rien de plus : votre fonction garde le rôle sous lequel elle a été déployée

La légende des tags (fn / cl / mc / adm) appartient à Notions essentielles.

Ce qui vaut pour toute vérification.

ToujoursCe que c'est
a credentialne porte aucune liste de rôles : elle nomme une identité, et les rôles se résolvent côté serveur à chaque requête. Une révocation invalide aussitôt la résolution en cache au lieu d'attendre la fin de sa borne de péremption déclarée
delegationchange la portée, jamais la capacité : agir pour le compte d'un joueur change quelles lignes sont visibles et à qui une écriture est attribuée, et n'accorde aucune opération que l'Actor ne tenait pas déjà
the verbrépond quel genre d'effet, et le prédicat répond quelles lignes. Si deux cas ne diffèrent que par la ligne de qui il s'agit, c'est un prédicat ; si l'effet lui-même diffère, c'est une autre opération et peut-être un autre verbe — d'où le fait que « expulser » soit administer plutôt que write avec un prédicat large
a hidden rowrépond not found : un refus ne doit pas devenir un oracle d'existence
an ownerse voit toujours lui-même, quoi que dise un prédicat par ailleurs
visibilityn'est pas de la sécurité — une optimisation de Channel et une permission sont des mécanismes différents, et ni l'un ni l'autre ne remplace l'autre
a disabled modulen'a aucune surface : qu'un module soit activé est une propriété du build, donc la codegen n'émet rien pour un module désactivé et un appel indisponible est une erreur de compilation plutôt qu'un refus à l'exécution
a module's surfacesuit l'Actor plutôt que le côté (Autorité le défend, et voici son mécanisme) : un build room-visitor voit entrer, sortir et lire, un build room-owner voit en plus expulser, fermer et configurer, et whoami rapporte quelles interfaces l'Actor courant débloque
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

Qui distribue un rôle. Accorder et révoquer un rôle à un joueur, et le rôle par défaut que le Project déclare pour un nouveau joueur, sont des opérations d'Auth & Players — ce module possède les identités, et un rôle est résolu par l'identité présente dans la credential. Cette page possède ce qu'un rôle est ; cette page-là possède le fait de le remettre.

Erreurs

  • Ce qu'un prédicat cache répond not found, pas forbidden — sinon le refus lui-même dit à l'appelant que la chose existe, ce qui est précisément ce que le fait de la cacher visait à éviter.
  • Un droit que l'appelant ne tient pas répond forbidden là où l'existence du sujet n'est pas un secret, et il nomme ce qui manquait au lieu d'échouer à blanc.
  • Un champ hors du masque est absent de la réponse, pas présent et vide : une valeur vide et une valeur masquée seraient indiscernables.
  • Un rôle qui s'inclut lui-même, directement ou par une chaîne, est une erreur de configuration — refusé en tant que Declaration plutôt que résolu à l'exécution.
  • La délégation n'élargit jamais la capacité : un appel que l'Actor ne pouvait pas faire pour son propre compte est refusé quand il est fait pour celui d'un joueur.

Limites

Chaque plafond nomme son comportement au bord ; les nombres arrivent avec le chapitre des limites de la plateforme.

  • La taille d'une sélection sous un prédicat de ligne est bornée, et le modèle d'ACL déclare cette borne au lieu de la découvrir. Une lecture au-dessus du plafond reçoit la valeur du plafond en lignes et le marqueur qui dit qu'elle a été coupée, jamais une page courte silencieuse.
  • La borne de péremption d'une permission résolue est déclarée, et une révocation ne l'attend pas — elle invalide aussitôt.

Parcours utilisateur

Une clé d'organisateur de tournoi, de la composition du rôle à un changement de permission en direct.

How the SDK works

Comment le SDK est construit

Deux questions sont confondues l'une avec l'autre, et toutes deux ont des réponses courtes. Comment le SDK est écrit — pourquoi une même idée a l'air légèrement différente en Python et en C++ Unreal. Comment le SDK s'exécute — ce qui se tient entre votre appel et le fil. Cette page répond aux deux une fois pour toutes, pour qu'aucune page de module n'ait à le faire.

Écrit du général au particulier

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

Le SDK est une seule conception avec deux échappatoires de rétrécissement, et l'ordre est la règle : rien ne descend d'un niveau tant que le niveau au-dessus peut réellement le porter.

NiveauCe qui vit ici
Les principes communsIdentiques dans chaque binding : un comportement est déclaré comme un attribut à côté de la chose qu'il décrit ; chaque Declaration que vous poussez est une Declaration que le panneau d'administration rend ; votre code s'adresse à des modules et à rien d'autre.
La forme du langageSeulement ce que le paradigme d'un langage ne peut pas exprimer de la manière commune. C# a des attributs et Python a des décorateurs — la même Declaration, écrite comme chaque langage écrit déjà cette idée. Un langage sans une telle construction porte la même Declaration autrement, et ce porteur est nommé là où il s'applique plutôt que supposé.
La forme du moteurSeulement ce qu'un moteur de jeu remodèle par-dessus son langage. Le C++ Unreal n'est pas du C++ ordinaire — il a son propre modèle d'objets et sa propre réflexion à la compilation, si bien qu'une Declaration y voyage à l'intérieur de la macro de réflexion du moteur, à l'endroit où cette macro prend déjà des spécificateurs. Le C# d'Unity n'est pas non plus le C# serveur : un runtime plus ancien, une bibliothèque de base plus petite.

Lu de haut en bas, voilà pourquoi les six onglets de chaque exemple ne sont pas six API différentes. Ce sont une seule API, écrite de six façons, et les différences que vous voyez sont les deux niveaux inférieurs qui transparaissent.

Comment il s'exécute, depuis votre code vers le bas

Le code de votre jeu voit des modules. Ce n'est pas une simplification pour la documentation — c'est tout le contrat du niveau supérieur.

  • Les modules sont ce à quoi vous vous adressez. Ils forment un graphe, pas un arbre, et ce que cela apporte est Inheritance & Composition.
  • Les quatre Primitives sont ce dont les modules sont assemblés — Events, RPC, data et abonnements, Groups. Une Room, un chat et un pool de matchmaking sont la même Primitive de Group sous des règles différentes. Si une fonctionnalité ne peut pas s'exprimer à travers les quatre, c'est un défaut de conception, pas un argument pour une cinquième.
  • Le hub est en dessous, et vous ne l'appelez jamais : injection de dépendances, montage des modules, session utilisateur, récupération d'état, et qualité de service des messages. Il est nommé une fois sur Sous le capot.
  • Les adaptateurs de transport se tiennent tout en bas, un par protocole, et le hub les cache complètement. Il y en aura plusieurs — WebSocket, notre propre UDP, HTTP — et lequel porte un appel n'est pas quelque chose que votre code décide ni remarque.

La seule chose qu'un module vous dit de la livraison est sa qualité de service — au moins une fois, ou au plus une fois. Tout le reste de la façon dont les octets sont arrivés là n'est délibérément pas à vous de savoir, parce que c'est la part que nous nous réservons le droit de rendre plus rapide.

Ce que vous en retirez

  • Un seul SDK, pas un client et un serveur. Ce qu'un appel a le droit de faire est l'habilitation de l'Actor, pas un drapeau de build. C'est Autorité, et c'est la décision de loin la plus lourde de conséquences sur cette page.
  • Une Declaration est l'entrée de tout. Poussez-la et l'API typée apparaît, le panneau d'administration la rend, et la codegen de chaque binding suit. Voir Schema as Code.
  • Les modules se composent au lieu d'hériter. Comment, et ce que « héritage » veut honnêtement dire ici, c'est Héritage et composition.
How the SDK works

Threads, durée de vie et tests

La boucle est à vous. Nous livrons à exactement un endroit, et jamais dans votre dos. Le SDK ne démarre aucun thread dont vous ayez à connaître l'existence, ne vous remet aucun verrou, et appelle votre code depuis un seul contexte que vous avez choisi au démarrage. Appelez-nous depuis n'importe quel thread ; nous vous appelons depuis un seul.

Un seul contexte de livraison, et la boucle est à vous

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

Une instance déclare exactement un contexte de livraison — l'endroit unique où tous ses gestionnaires s'exécutent. Il est fixé à l'initialisation et ne change pas de toute la vie de l'instance. Un Event, un Delta de données, l'issue d'un appel : tous arrivent là et nulle part ailleurs.

Il en existe deux formes, et vous en choisissez une au démarrage :

  • C'est vous qui le pompez. Le runtime ne fait rien de lui-même ; vous videz les livraisons en attente depuis votre propre boucle. C'est la forme que veut un moteur — les livraisons atterrissent sur le thread de jeu, dans une frame que vous avez choisie.
  • C'est nous qui le tenons. Le runtime tient un fil d'exécution dédié. C'est la forme que veut un hôte console ou un serveur dédié.

Aucune des deux n'est un repli pour l'autre, et il n'y a pas de troisième option impliquant un pool de threads. L'intérêt de promettre un seul contexte est que vous n'ayez jamais à demander combien de threads nous avons faits.

Le contexte n'est jamais un argument. Aucun gestionnaire ne prend un paramètre « sur quel thread suis-je », et il n'y a rien à interroger. L'endroit où votre gestionnaire s'exécute est une propriété du contrat, pas une donnée de l'appel.

Démarrer et arrêter sont explicites

L'initialisation est un appel que vous faites, et il répond par une issue. Rien ne s'initialise paresseusement à la première utilisation — c'est interdit plutôt que simplement déconseillé, et la raison vaut une phrase : un démarrage paresseux déplace l'unique endroit où un module désactivé est visible vers l'appel arbitraire qui s'est trouvé être le premier, où cela se lit comme l'échec de cet appel-là.

Un module désactivé est nommé au démarrage, et l'issue dit laquelle de deux choses s'est produite : toute la chaîne dépendante est éteinte, ou bien vous tournez avec moins, plus la liste de ce qui est indisponible. Il n'y a pas de troisième cas silencieux.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Arrêter n'annule rien. C'est le seul endroit où une habitude prise de la plupart des SDK est activement fausse ici. L'arrêt est explicite, complet et idempotent — une fois qu'il a réussi, aucun gestionnaire de cette instance n'est rappelé — mais il ne dit rien du travail déjà en vol. Une opération que vous avez lancée avant l'arrêt reste découvrable par le moyen que cette opération a nommé. Si vous avez besoin de savoir si un achat est passé, l'arrêt n'est pas la façon de le découvrir.

Chaque handle a une fin déclarée — et ce n'est jamais le ramasse-miettes

Un abonnement, un descripteur de travail différé, une session : chacun est un handle, et chacun a exactement une fin que le contrat nomme. Libérer est idempotent, donc libérer deux fois n'est pas une erreur.

Trois conséquences faciles à manquer :

  • La fin n'est jamais un finaliseur, un destructeur ni une portée. Un handle que vous laissez tomber par terre reste ouvert. C'est un bug dans votre code, pas quelque chose que nous récupérons discrètement — parce qu'une durée de vie qui dépendrait du langage serait une durée de vie différente dans chaque binding.
  • Utiliser un handle après sa fin est un refus déclaré, avec un code. Pas un résultat vide, pas un comportement indéfini, et pas une erreur générique d'objet libéré qui ne porte rien d'exploitable.
  • Une connexion perdue n'est pas la fin d'un handle. Un abonnement survit à une déconnexion et continue de recevoir après la reconnexion. Les handles prennent fin pour les raisons que le contrat nomme, et perdre le réseau n'en est pas une.

Aucun handle ne survit à l'instance qui l'a émis : une fois que vous avez arrêté, chaque handle qu'elle vous a donné est à sa fin.

Appeler depuis un gestionnaire est bien ; attendre à l'intérieur ne l'est pas

Appelez la surface depuis n'importe lequel de vos threads. Chaque handle est utilisable depuis tout thread, et c'est une promesse plutôt qu'une propriété du build du jour. Vous ne prendrez jamais notre verrou, n'attendrez jamais sur notre barrière, et on ne vous dira jamais d'appeler quelque chose « sous un verrou » — aucune primitive de synchronisation ne fait partie de la surface, tout court.

Les gestionnaires d'une même instance sont sérialisés : deux ne s'exécutent jamais en même temps, et l'ordre à l'intérieur d'un même Stream est préservé. Un gestionnaire n'a donc besoin d'aucun verrouillage à lui.

Sérialisé ne veut pas dire dédupliqué. L'ordre est une promesse ; combien de fois un message est livré en est une autre, déclarée sur le type du message. Sous « au moins une fois », vous verrez le même message deux fois, et la clé de déduplication qui voyage toujours avec lui est la façon de le savoir.

Lancer une opération depuis l'intérieur d'un gestionnaire est légal et ne peut pas provoquer d'interblocage. Son issue, en revanche, n'arrive jamais à l'intérieur de ce même gestionnaire — elle revient comme une livraison distincte sur le même contexte. Aller vers l'intérieur est permis ; se retourner à l'intérieur ne l'est pas.

Bloquer le contexte de livraison est interdit, et l'interdiction n'est pas un conseil. Attendre le réseau, attendre le verrou de quelqu'un d'autre, attendre de façon synchrone votre propre appel : tout cela est interdit à l'intérieur d'un gestionnaire. L'interdit a un symptôme — un gestionnaire qui retient le contexte au-delà de son budget déclaré produit soit une dégradation déclarée de la livraison, soit un refus déclaré. Ce qu'il ne produit jamais, c'est un ralentissement silencieux que vous découvririez dans la session d'un joueur.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

« Annuler » recouvre deux choses différentes

Un seul mot dans la plupart des langages, deux opérations ici, et la différence est observable :

Ce que vous voulezCe que c'est
arrêter de me livrerlocal. Réussit toujours, y compris connexion coupée. Libérer un abonnement, c'est ceci.
arrêter le travailune demande à la plateforme. Idempotente, et elle ne promet rien quant à savoir si le travail a eu lieu.

La seconde est celle que l'on se figure mal. Annuler un travail accepté est une demande qui peut ne pas arriver à temps — exactement comme un délai dépassé, qui ne veut pas dire non plus « cela ne s'est pas appliqué ». Après l'une ou l'autre annulation, l'issue d'une opération non idempotente lancée reste découvrable par le moyen que cette opération a nommé.

Et les deux échecs sont distinguables : annuler un appel qui ne nous est jamais parvenu est un échec local ; annuler un travail que nous avions accepté vous donne un état terminal issu d'un ensemble déclaré.

Ce que vous recevez est une copie du passé

Une valeur livrée à votre gestionnaire ne change pas ensuite. Nous ne remettons jamais une référence vivante vers notre propre état, si bien que rien de ce que vous tenez ne mute entre deux lignes de votre code.

Conserver une valeur livrée au-delà du gestionnaire est donc sûr — mais ce que vous avez conservé est l'observation d'un instant, pas une fenêtre sur le présent. Les Deltas peuvent être fusionnés en chemin vers vous, si bien qu'une liste de valeurs que vous avez sauvegardées n'est pas un historique de ce qui s'est passé.

Vous ne possédez pas non plus nos tampons. Il n'y a pas d'emprunt-et-rendu, pas d'assembler-puis-envoyer : modifier un état déclaré est l'opération réseau.

Tests : une implémentation en mémoire, pas un mock

Il existe une implémentation en mémoire complète — même surface, même ensemble d'issues déclarées, pas de réseau. C'est une chose distincte dont vous dépendez, pas un drapeau sur le runtime de production.

  • Elle n'est pas partielle. Une opération qu'elle ne supporte pas est refusée avec un code déclaré, jamais répondue par un succès inventé. Un test qui passe contre elle passe pour une raison.
  • Le temps vous appartient. Les échéances déclarées — la durée de vie d'un descripteur de travail, une réservation, une fenêtre de rétention — sont atteintes en avançant d'un pas, pas en dormant.
  • Le déterminisme est déclaré et borné : ordre à l'intérieur d'un Stream, mode de livraison déclaré, temps contrôlable. Le déterminisme en virgule flottante n'est pas promis, une simulation complète ne se rejoue donc pas ici non plus.

La différence avec un mock est justement l'enjeu. Un mock vérifie que vous avez appelé ce que vous vouliez appeler. Ceci vérifie que ce que vous avez appelé a du sens.

Ce que vous installez, et le plancher de version

Le cœur est une unité ; les modules optionnels en sont d'autres, chacun avec une composition déclarée et une liste déclarée de dépendances obligatoires. Ajouter une unité ne change jamais la surface d'une autre — un module se monte là où sa Declaration le dit, donc rien n'apparaît ni ne disparaît ailleurs à cause de ce que vous avez installé à côté.

Si une unité optionnelle est référencée mais ne peut pas se charger, c'est une issue déclarée de l'initialisation — au même endroit où un module désactivé est signalé. Jamais un bouchon qui ne fait silencieusement rien.

Chaque binding déclare la version minimale du runtime contre laquelle il est construit. En dessous, vous obtenez un refus à l'initialisation, pas un fonctionnement partiel : un runtime trop ancien casse sinon à la première capacité qui lui manque, laquelle se trouve quelque part d'arbitraire dans votre code et le plus souvent sur la machine d'un joueur plutôt que sur la vôtre. Relever ce minimum est un changement cassant et passe par le même processus que n'importe quel autre.

Où aller ensuite

Building blocks

Core

Core est l'unique objet que vous créez, et tout le reste y est accroché. Une clé en entrée, et vous tenez le contexte, l'identité, les échecs typés, le traçage et le regroupement en lots. Chaque appel de module passe par lui, et aucun module n'en livre sa propre version.

Who addresses Core, the 4 things it provides, and the 1 module it builds on

Quand l'utiliser

  • Vous avez besoin de savoir qui et où vous êtes — identité, rôles, modules débloqués, Project · env · région, le tout sur l'unique objet que vous tenez.
  • Une fonction cloud doit écrire en tant que joueur — l'écriture est attribuée à ce joueur, et l'enregistrement nomme les deux parties : la fonction et le joueur.
  • Les réessais ne doivent jamais s'appliquer deux fois — les opérations en lot portent une clé d'idempotence.
  • Un échec doit être branchable et cherchable — chaque levée est un Problem typé avec un code stable.
  • Passez votre chemin quand vous cherchez de la messagerie, des appels ou de l'état — ce sont les Primitives : Events, RPC, Data.

Qui fait quoi

ActorSur cette page
any actorlit l'identité, le contexte, les rôles via Whoami
backend-serviceagit en tant que joueur ; regroupe en lots des opérations idempotentes
operatorlit les traces des appels échoués ou réessayés

En un coup d'œil

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

L'identité vit à l'intérieur de Core lui-même. Aucun paramètre session ou ctx n'apparaît jamais dans un appel.

Le modèle

Ce que porte chaque erreur.

ChampCe que c'est
codele nom du refus, lisible par machine, et il est stable. Le vocabulaire est une projection des codes existants de la plateforme : un nouveau code pour un refus que la plateforme nomme déjà est interdit
categoryla classe dont relève le refus, et c'est elle qui dit si un réessai a le moindre sens
trace identifierl'identifiant de cette occurrence précise, présent toujours, erreurs locales comprises, si bien que contacter le support n'exige jamais de reproduire d'abord la panne
explanationdu texte humain destiné à être lu, et il n'est pas stable : les titres et les explications changent et sont localisés à tout moment
per-field errorsla liste que porte un refus de validation : champ, code, message

Un consommateur branche sur le code et la catégorie, jamais sur du texte humain — ni par comparaison, ni par sous-chaîne, ni par analyse. Une erreur dont seul le message est atteignable est un défaut du binding plutôt qu'une forme à contourner.

Trois origines, et ce ne sont pas la même chose.

OrigineCe qui s'est passé
platformelle a répondu par un refus, portant un code du catalogue de la plateforme
localle SDK a refusé avant d'envoyer, depuis son propre vocabulaire publié
unknownl'appel a été envoyé et aucune réponse n'est revenue. Ni « la plateforme a dit non » ni « nous n'avons jamais demandé »

Ce qui vaut pour tout refus.

ToujoursCe que c'est
a refused operation applied nothingl'atomicité est l'obligation de la plateforme, pas la vôtre : aucune lecture de compensation sur une branche d'erreur ordinaire. Exactement deux cas font exception et tous deux le disent là où ils surgissent — un délai dépassé, dont l'issue est inconnue, et un lot sous sémantique par élément
the delivery pathne change pas l'erreur : le même code, la même catégorie et la même origine vous parviennent que le binding lève, renvoie une valeur de résultat, ou rappelle sur un abonnement. Un chemin qui porte moins qu'un autre est un défaut de ce binding
a timeoutn'est pas une issue : c'est la troisième origine ci-dessus, et ce qu'il faut en faire est déclaré par opération plutôt que deviné

Core porte le contexte, pas les messages. Émettre des faits et s'y abonner, c'est la Primitive Events ; les appels — requête/réponse, sens unique, fan-out vers un Group — sont la Primitive RPC ; l'état, les abonnements et les lectures en flux sont la Primitive Data, adressée à travers Entity. Les audiences vers lesquelles ces trois-là partent en fan-out sont la quatrième Primitive, Groups. Les transferts dimensionnés (uploads, téléchargements) affleurent dans Files & UGC. La sémantique de montage — espaces de noms, rejet des collisions au moment du montage — vit sur Sous le capot.

Erreurs

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

Ce que voit un appelant sans le rôle. Les lignes fn et adm sont refusées, pas dégradées : un joueur ou une session cliente qui appelle « agir en tant que joueur », émet une trace ou en lit une obtient un Problem de code forbidden — la credential est valide, les rôles derrière elle ne portent pas ce droit, et répéter l'appel avec la même clé d'idempotence n'y change rien. Il n'y a pas de variante réduite qui s'exécuterait avec moins de droits et renverrait moins.

Chaque échec est un Problem typé avec un code stable : les mêmes codes que documente le contrat de fil, si bien qu'un client peut brancher dessus et qu'un humain peut les chercher.

Limites

Une limite s'applique à l'admission. Un appel qui a été accepté a déjà passé la limite et sera mené à bien, quel que soit le temps qu'il attende d'être traité ; un refus qui tombe sur un appel ultérieur ne fait rien au travail déjà admis. Une file qui s'est remplie est donc une file qui tourne — réessayer l'appel accepté parce qu'un voisin a été refusé, c'est ainsi qu'on fait le travail deux fois.

Vous apprenez une limite en étant refusé, et il n'y a rien d'autre à lire. Le SDK n'expose ni la valeur en vigueur, ni la marge restante, ni un avertissement disant qu'on en approche, et rien d'une limite n'est jamais mis devant un joueur. Le refus porte le tout :

  • la catégorie, qui est ce qui dit si un réessai a seulement un sens
  • à qui était la limite
  • quand un réessai est permis, et sur quelle fenêtre

Branchez là-dessus. Il n'y a pas de compteur à interroger ni de budget à afficher.

Parcours utilisateur

Un appel qui échoue, de la levée jusqu'à la trace que lit un opérateur.

Building blocks

Events

Un Event est le fait que quelque chose est arrivé, livré à tous ceux qui doivent l'apprendre. Employez-le pour ce qui arrive une fois et ne peut pas être rattrapé depuis une valeur courante — un tir, un achat, une Room rejointe.

Who addresses Events, the 5 things it provides, and the 1 module it builds on

Quand l'utiliser

  • Quelque chose est arrivé et d'autres doivent réagir — un tir parti, une porte verrouillée, une partie terminée.
  • L'audience varie — la même émission atteint une escouade, une Room, ou un seul Actor, selon la cible que le type déclare.
  • Vous voulez des gestionnaires typés avec autocomplétion — un Event déclaré devient send. et on. sur sa surface, chacun avec son propre contrat.
  • Le fait doit rester lisible une heure plus tard — déclarez le type retenu et relisez-le par période.

Qui fait quoi

ActorSur cette page
schema-authordéclare les Events avec [Event], pousse le schéma
any actorémet via send., s'abonne via on.

En un coup d'œil

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

Un Event déclaré à l'intérieur d'un module ou d'un Group n'affleure que là : squad.send.rallyCall existe parce que rally_call est déclaré pour les escouades, et l'émission atteint les membres de l'escouade. Un Event qu'un module émet vers l'extérieur fait partie de son contrat déclaré ; les appelants n'apprennent jamais l'existence de ceux qui ne le sont pas.

Le modèle

Ce qu'un type d'Event déclare.

DéclareCe que c'est
nameun nom de fil explicite, déclaré plutôt que dérivé du symbole
payloadle schéma de ce que porte une émission
targetoù vont les émissions de ce type : une instance d'Entity, un Group, une Room ou le contexte global. Une cible de Group est une livraison en masse — un signal, beaucoup de destinataires. Un destinataire changeant s'exprime comme un Group, jamais comme une adresse passée à l'émission
clocksim_time ou timestamp, jamais les deux — sim_time pour les faits internes à une simulation, qui participent à la prédiction, à la compensation de lag et au rembobinage ; timestamp pour les faits extérieurs, comme un achat ou une connexion
retentiontransient — atteint qui est abonné au moment de l'émission et n'est pas stocké ; ou retained — stocké et relu par type et par période, non par la surface de requête que porte Data. Déclaré, jamais inféré du genre d'Event
termsur un type retained : combien de temps il est gardé, et ce qui arrive à l'expiration. « Pour toujours » n'est pas l'une des valeurs
deliveryau plus une fois, au moins une fois ou exactement une fois — déclaré sur le type, si bien qu'un abonné n'a jamais à demander lequel une émission a employé ; « exactement une fois » énonce les bornes dans lesquelles cela tient
contextle contexte dans lequel le type est déclaré, global ou local. Un nom déclaré globalement est visible dans les contextes locaux ; un nom déclaré localement n'est pas visible au-dessus. Ce qu'un module émet est son contrat dans les deux cas — un appelant n'apprend jamais l'existence d'un Event non déclaré

Ce que porte une émission.

ChampCe que c'est
typel'Event déclaré. Deux émissions ne fusionnent jamais : deux tirs sont deux Events, et le second n'absorbe pas le premier — c'est ce qui sépare un Event du champ [Sync] que porte Data
payloadconforme au schéma du type
sourcel'Actor émetteur, plus son instance quand c'est une Entity qui a émis. Un Event émis par un client est une affirmation, pas un fait : le côté qui fait autorité le vérifie avant que quoi que ce soit en dépende
stampsur l'horloge déclarée du type
dedup keyprésente sous tous les modes de livraison, parce que la re-livraison est possible dans tous — un doublon de transport, une seconde lecture d'un Event retenu
cause keysur un Event que la plateforme émet à cause d'un autre Event de plateforme : l'id de ce dont il découle, si bien qu'une chaîne se reconstruit par clé et jamais en comparant des estampilles

Ce que tient un abonnement.

TientCe que c'est
eventle type déclaré auquel il est lié
surfacele nœud sur lequel il est pris, à l'intérieur du target déclaré du type — la moitié de l'audience qui revient à l'abonné
handlertypé sur la charge utile
positionl'endroit d'où il reprend, déclaré, si bien qu'une reconnexion ne redémarre pas silencieusement à « maintenant ». Ce qui a été manqué dans l'intervalle n'est pas rejoué : un Event transient est irrécupérable, et seul un Event retained peut être relu

Ce qui vaut pour tout Event, quoi que le type déclare.

ToujoursCe que c'est
audiencejamais énumérée par l'émetteur : c'est le target déclaré du type restreint à qui est abonné, puis filtré par Access — publier et s'abonner sont des droits distincts et ni l'un ni l'autre n'implique l'autre, et un flux peut être fermé par un prédicat même là où le type lui-même est visible. Un émetteur capable de lister les destinataires devrait reproduire ce que Groups et Data savent déjà
phasesémis, puis livré — et rien d'autre. Un Event n'a pas de machine à états : il arrive une fois
orderingpromis à l'intérieur d'un même Stream, et pour les Events un Stream est une seule instance émettrice : deux Events de la même instance arrivent dans l'ordre d'émission. Entre Streams aucun ordre n'est promis sous quelque forme que ce soit — ni entre deux instances, ni entre un Delta et un Event portant sur le même changement
crossing streamsquand un ordre entre Streams est nécessaire, le mécanisme est déclaré, jamais supposé : amenez les messages dans un même Stream, ou portez une estampille causale dans la charge utile
gap detectionlà où le mode admet la perte, l'abonné apprend le trou plutôt que de sauter silencieusement

Qu'un fait soit gardé après livraison est un emplacement de sa Declaration, pas une décision prise à l'émission — le même type est donc toujours gardé de la même façon et aucun appelant n'a à se rappeler quel appel était lequel.

TransientRetained
Atteintqui est abonné à cet instantcela, plus un abonné arrivant ensuite
Ensuitedisparugardé pour un terme déclaré
Relisiblenonoui, sur le terme
Au-delà du terme—une sélection refuse, au lieu de répondre vide
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

Erreurs

  • Publier et s'abonner sont des droits distincts, et ni l'un ni l'autre n'implique l'autre. Un abonnement sans le droit répond forbidden, pas not-found — le type est dans le contrat déclaré du module, il n'y a donc rien à cacher.
  • S'abonner à un type que le module n'a pas déclaré est une erreur de contrat, exposée comme un Problem typé — jamais un silence sans effet.
  • À l'émission, trois refus de validation : un type non déclaré, une charge utile qui échoue au schéma, et une cible que le type n'autorise pas.
  • Une sélection au-delà du terme d'un type retenu refuse, au lieu de répondre par une page vide.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Taille de la charge utile — au-dessus du plafond, la publication échoue et l'Event n'a pas lieu, jamais une charge utile tronquée.
  • Cadence de publication par source — un refus de limite de débit portant l'instant du réessai.
  • Abonnements par Actor — le nouveau est refusé et les existants sont gardés.
  • Volume de rétention par type — éviction selon la politique déclarée, par terme, jamais au hasard.

Parcours utilisateur

Un appel de ralliement, de la Declaration jusqu'au marqueur que chaque membre de l'escouade voit sur son propre écran.

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

RPC

Un appel typé dont le corps vit ailleurs. Le RPC est la deuxième Primitive : déclarez la procédure là où elle appartient — sur un module, ou à l'intérieur d'une Entity — et chaque binding obtient une méthode générée et attendable. Le verbe est invoke : le sens unique est un mode que la Declaration nomme, pas un second verbe, et il n'y a pas de do.

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

Quand l'utiliser

  • L'appelant a besoin d'une réponse — requête/réponse avec un retour typé.
  • L'appelant signale et passe à autre chose — un RPC à sens unique déclaré, rien ne revient.
  • Le travail survit à l'appel — un RPC différé déclaré rend un descripteur de travail au lieu d'un délai dépassé.
  • Une question, plusieurs répondants — un appel de Group est N appels, et chaque réponse arrive liée au membre qui l'a envoyée.
  • Le verbe appartient à une chose — déclarez-le à l'intérieur de l'Entity ; le RPC d'une Entity ne vit nulle part ailleurs (Entity montre la Declaration).
  • Passez votre chemin quand personne n'est prié d'agir — un fait auquel les autres se contentent de réagir est un Event.

Qui fait quoi

ActorSur cette page
schema-authordéclare les RPC, leurs modes et qui a le droit de les appeler
any actorinvoque un appel avec réponse ou à sens unique, là où la Declaration l'autorise
group memberrépond à un appel en fan-out ; une réponse revient par membre

En un coup d'œil

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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);

Où le corps s'exécute — fonction cloud, client, master-client ou serveur de jeu — est du routage, déclaré par méthode ; Extensibility couvre les redéfinitions et les intergiciels. Un appel échoué lève un Problem typé (Core).

Le modèle

Ce qu'un RPC déclare.

DéclareCe que c'est
nameissu du vocabulaire des verbes
inputles arguments que l'appelant doit choisir
outputexactement un type déclaré. Une réponse plus courte est un type déclaré à part entière, jamais le même type avec des champs discrètement omis — sinon « pas demandé », « l'objet est absent » et « caché par le masque d'accès » deviennent une seule absence indiscernable
reply modewith a reply — une valeur du type de sortie déclaré ou un refus typé ; ou one-way — pas de réponse, et l'appelant n'apprend qu'un échec d'envoi local. Le sens unique ne doit pas servir là où l'appelant a besoin de l'issue : une issue inconnue coûte plus cher qu'un refus connu
execution modeimmediate — l'issue revient dans l'appel ; ou deferred — l'appel renvoie un descripteur de travail et l'issue est lue ou arrive par abonnement. Déclaré, jamais choisi par l'implémentation selon la charge, parce que l'appelant bâtit son comportement sur la forme de la réponse
streamingsi l'entrée et la sortie arrivent par parties et sont traitées à mesure, plutôt que d'un bloc
idempotencyun RPC à sens unique porte lui aussi une clé d'idempotence : pas de réponse ne veut pas dire pas de re-livraison
overridabilitydéclarée sur la méthode elle-même. Pas de Declaration veut dire non redéfinissable — jamais redéfinissable par défaut
contextlà où il est déclaré. Un RPC déclaré à l'intérieur d'une Entity fait partie de cette Entity et n'existe pas en dehors d'elle. En déclarer un dans le serveur de jeu, c'est l'enregistrer dans le routeur — il n'y a pas de seconde façon d'en ajouter un

Ce que porte une invocation.

PorteCe que c'est
argumentsseulement ce que l'appelant doit choisir
implicit contextle récepteur, l'appelant et le contexte ambiant, liés avant votre premier paramètre écrit — on ne demande jamais à la méthode d'une Entity l'identifiant de cette Entity
referencesun argument qui est un objet du SDK voyage comme un Ref typé — un identifiant ou un curseur, jamais une copie de son contenu. Le destinataire le résout pour son propre compte, sous les mêmes permissions et prédicats : une référence est une adresse, pas une permission accordée
outcomeune valeur du type de sortie déclaré, ou un Problem typé

Ce que tient le descripteur d'un appel différé.

TientCe que c'est
stateaccepted → running → completed ou failed, les deux derniers terminaux
lifetimedéclarée ; au-delà, l'issue est indisponible et la demander est un refus, pas une réponse vide
cancelidempotent, et honnête : il demande, et l'état terminal que vous observez est celui, completed ou failed, que le travail a atteint

Ce qui vaut pour tout RPC.

ToujoursCe que c'est
one handlerexactement un gestionnaire logique — c'est ce qui sépare un RPC d'un Event, où il peut n'y en avoir aucun. Adresser un Group est donc N appels et non un seul : Groups fournit les adresses, et les réponses reviennent comme un Stream, chacune liée au membre qui l'a envoyée
meaningune demande d'accomplir une action, là où un Event est l'affirmation d'un fait. Un RPC à sens unique et un Event se ressemblent de l'extérieur et ne sont pas la même chose : le gestionnaire d'un RPC est obligé d'exister, un Event peut n'avoir aucun destinataire et c'est normal
no state machineune Declaration n'en a pas, et un appel immédiat n'en a pas — soit il a renvoyé une issue, soit non, et alors les règles du délai dépassé s'appliquent. Seul un appel différé a des états observables
a streamn'est pas atomique : une sortie en flux ne promet rien sur le tout : un récepteur doit être prêt à une interruption et à distinguer « le Stream s'est achevé » de « le Stream a été interrompu »
no predicate on a writeaucune écriture n'accepte un prédicat en entrée : « fais ceci pour tous ceux qui remplissent cette condition » n'est pas une opération. Une action de masse s'exprime par énumération — lisez l'ensemble, passez la liste à une opération par lots dont la sémantique d'échec partiel est déclarée. En entrée d'une écriture, un prédicat est évalué à un instant que personne n'a nommé, sur un ensemble que personne n'a vu

Chaque RPC atteint son gestionnaire par le routeur, et laquelle de ses directions répond est déclarée par méthode plutôt que d'être une propriété du site d'appel — voir Extensibility.

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

Erreurs

  • Le droit est sur le RPC, jamais sur la Primitive. Il n'existe pas de « a le droit d'invoquer » général : chaque Declaration nomme l'atome que son appelant doit tenir, et un appelant qui ne l'a pas obtient un refus typé « interdit », code compris — pas un abandon silencieux.
  • Une instance cachée se lit comme « not found ». Un RPC d'Entity invoqué sur une instance que le prédicat de ligne de l'appelant cache répond exactement comme le ferait la lecture de cette instance, si bien que le refus ne dit rien à l'appelant sur ce qui existe.
  • Aucun gestionnaire, c'est « indisponible », pas « not found ». Le RPC est déclaré, il existe donc ; ce qui manque est une route. Ce refus est répétable — un serveur de jeu peut revenir — alors que « not found » dirait à l'appelant d'arrêter d'essayer.
  • Un refus de Hook porte le code et la raison propres au Hook, si bien que « rejeté par une règle du jeu » n'arrive jamais avec l'air de « le transport a cassé ».
  • Un délai dépassé n'est pas une issue. Pour un appel différé, vous lisez le descripteur ; pour un appel immédiat, la clé d'idempotence déclarée est ce qui rend le réessai sûr — y compris sur un appel à sens unique, où l'absence de réponse n'est pas une absence de re-livraison.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Taille de l'entrée — l'appel est refusé avant exécution.
  • Taille de la sortie — refusée plutôt que tronquée, parce qu'une réponse rognée est indiscernable d'une réponse complète.
  • Cadence d'appel par Actor — un refus de limite de débit nommant le moment du réessai.
  • Appels différés simultanés par Actor — le nouveau est refusé et ceux en vol s'achèvent.
  • Durée de vie du descripteur — au-delà, l'issue est indisponible, et c'est un refus.
  • Profondeur de la chaîne d'appels — un refus déclaré en cas de dépassement, jamais des ressources épuisées ni une rupture silencieuse.

Parcours utilisateur

Un score soumis, un ping signalé, une escouade à qui l'on demande si elle est prête.

Building blocks

Data

Vous changez un champ. Tout ce qui suit en aval arrive sans une seule ligne de code. Data est la troisième Primitive : la mécanique sous chaque champ synchronisé — des Deltas par rapport au dernier état accusé, l'aspect comme unité de politique, la priorité et la cadence d'envoi, des abonnements reprenables, la fenêtre retenue, et des Hooks d'avant et d'après changement.

Vous adressez des Entities, pas des tables — voir Entity pour la surface de lecture et de modification (trouver, filtrer, trier, paginer, s'abonner à une sélection) ; cette page est la mécanique du dessous. Il n'y a pas de chemin consommateur vers une table, et pas de seconde façon d'écrire : un changement est une Operation d'Entity, et le Delta en découle.

Who addresses Data, the 5 things it provides, and the 1 module it builds on

Quand l'utiliser

  • Vous avez besoin d'un état répliqué vers les clients sans code de snapshot — changer un champ est toute la synchronisation.
  • Les champs diffèrent en urgence ou en audience — priorité et plafond de cadence d'envoi par aspect, et un prédicat de visibilité pour le brouillard de guerre.
  • Un client qui se reconnecte ne doit pas diverger en silence — un trou est détecté et nommé, et un trou au-delà de la fenêtre retenue reçoit l'état complet en réponse.
  • Vous avez besoin du passé récent — la fenêtre retenue de Deltas, indexée par sim_time, est ce que lisent la prédiction et la compensation de lag.
  • Une règle de validation appartient à un seul endroit — un Hook d'avant changement borne ou oppose son veto avant que le changement n'atterrisse.
  • Passez les boutons quand tout ce dont vous avez besoin est lire ou interroger — la surface d'Entity chevauche cette mécanique sans y toucher.

Qui fait quoi

ActorSur cette page
schema-authordéclare les aspects, leur politique de synchronisation et le prédicat de visibilité
any actors'abonne à une cible ; reprend depuis une position ; demande l'état complet
backend-serviceHooks d'avant et d'après changement
operatorlit le coût de paquet par Actor ; voit quand la livraison se dégrade ou qu'un paquet est coupé

En un coup d'œil

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

Le modèle

Ce que porte un Delta.

ChampCe que c'est
changed fieldsceux-là seulement, jamais l'objet entier
pairla paire instance × aspect à laquelle il appartient
numberun numéro de séquence au sein de cette paire, et c'est ce qui rend un trou détectable

Ce qu'un aspect déclare.

Tout par attribut, sur l'aspect ou sur un champ unique, jamais par un appel à l'exécution. L'aspect fixe la valeur par défaut et un champ peut la redéfinir ; l'aspect reste l'unité de politique, sans quoi il n'y aurait rien à partir de quoi assembler des presets.

DéclareValeurs, et ce que ce n'est pas
priorityordonne ce qui est envoyé en premier quand le canal ne suffit pas. Pas une promesse de latence : c'est relatif, et cela ordonne l'envoi entre champs plutôt que de garantir une échéance de livraison
max update rateune borne supérieure sur l'envoi. Pas une promesse de réception à cette cadence — la réception dépend du canal
delta onlyne pas envoyer ce qui n'a pas changé
delivery modeshared packet — la même chose pour tous, bon marché côté CPU ; ou per-actor packet — chacun le sien selon sa zone de visibilité, coûteux côté CPU et nécessaire à grande population
visibility rulele prédicat qui décide qui reçoit tout court — Visibility projette cette moitié en entier
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

Ce que tient un abonnement.

TientCe que c'est
targetune instance, une sélection, ou un aspect, et il reçoit les Deltas de cette cible. Une cible n'est pas un Stream : une même cible peut couvrir de nombreuses paires, et l'ordre est promis à l'intérieur d'une paire plutôt qu'à travers une cible
positionl'endroit d'où il reprend : le consommateur la présente. Si le trou est plus grand que la fenêtre retenue, c'est l'état complet qui arrive au lieu d'un flux de Deltas, si bien qu'une longue déconnexion ne laisse jamais un client silencieusement faux
stateactive → gap detected → resynchronised | closed, et closed est terminal

Ce qui vaut pour tout Stream.

ToujoursCe que c'est
mergingles Deltas l'admettent : 100 → 90 → 80 entre deux envois peut arriver comme 100 → 80, parce que l'état final reste correct. C'est exactement ce qui sépare un Delta d'un Event, où en perdre un perd de l'information pour de bon
gap detectionperdre silencieusement un Delta est interdit ; le numéro de séquence dans la paire est ce que le consommateur compte
orderingtient à l'intérieur d'une paire instance × aspect ; entre paires il n'est promis sous aucune forme
traversalne porte que sur des choses déclarées : ce qui peut être un filtre, un tri ou une inclusion est un champ déclaré et une référence déclarée. La surface de sélection d'Entity est la projection de ce modèle, et cette Primitive ne donne au consommateur aucun parcours à elle — il n'y a pas de second langage de requête
historyest construit à partir des Deltas : la fenêtre instantanée d'une Entity est une fenêtre retenue de Deltas indexée par sim_time. Sa profondeur est la limite de cette Primitive, et elle ne promet aucune reproductibilité sur les champs à virgule flottante
the packet budgetse dégrade comme déclaré : quand le budget par Actor s'épuise, la plateforme retombe sur le paquet partagé comme déclaré, plutôt que de se mettre à perdre des destinataires arbitrairement

Ce qu'un Hook a le droit de faire, et quand.

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

HookCe qu'il a le droit de faire
avant un changementle muter, ou y opposer son veto. Un changement vétoé ne produit aucun Delta — les abonnés ne voient rien, plutôt qu'une valeur puis une correction
après un changementajouter des effets de bord, et il ne peut jamais faire échouer le changement

La suppression est accrochée sur Entity, où vit la suppression ; cette Primitive accroche le changement.

Erreurs

  • Une cible d'abonnement non déclarée est un refus de validation.
  • Pas de permission de s'abonner répond forbidden ou not found selon que l'existence de la cible est elle-même un secret — le refus ne doit pas devenir un oracle.
  • Une position de reprise qui ne s'analyse pas est une requête invalide, jamais un redémarrage silencieux à maintenant.
  • Un abonnement fermé du côté de la plateforme est un conflit, et il est observable : le closed de la machine est terminal et l'atteindre n'est pas quelque chose qu'un client doive inférer.
  • Le compte d'abonnements épuisé est un conflit — la permission est tenue, la place ne l'est pas.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • La fenêtre de rétention des Deltas — reprendre depuis plus ancien que la fenêtre donne l'état complet plutôt qu'un refus.
  • Abonnements par Actor — un nouveau est refusé et les existants continuent.
  • Taille d'un Delta — le Delta est découpé plutôt que tronqué, et le découpage est observable.
  • La cadence d'envoi — une borne supérieure, pas une garantie.
  • Le coût d'un paquet par Actor — à l'épuisement, dégradation déclarée vers le paquet partagé.

Parcours utilisateur

Un changement de position, de l'affectation jusqu'au mouvement corrigé sur chaque écran.

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Groups

Une seule liste, un seul auditeur de masse. Un Group est la quatrième Primitive : un ensemble nommé d'Actors qui reçoit comme un seul. Vous adressez le Group et chaque membre entend — une Room, un chat, un pool de matchmaking et une liste de diffusion sont la même Primitive sous des règles différentes : une logique d'entrée et de sortie différente, une durée de vie différente, la même liste en dessous.

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

Quand l'utiliser

  • Vous avez besoin de groupes, d'escouades ou de guildes — des ensembles nommés de joueurs avec une capacité déclarée et, là où le type en déclare une, une durée de vie.
  • L'appartenance doit suivre une règle déclarée que la plateforme évalue — les nouveaux vétérans y entrent sans tâche planifiée, et sans appel de réévaluation à vous.
  • Vous voulez adresser beaucoup de joueurs à la fois : un Event déclaré part en fan-out avec send.*, un RPC déclaré atteint chaque membre et chaque réponse revient nommée.
  • Vous avez besoin d'un modèle d'appartenance unique réutilisé comme audience — une portée de Visibility, une conversation de Messaging, un groupe de Matchmaking.
  • Passez la création d'un Group quand l'ensemble est celui des membres d'une session — Rooms est cette Primitive avec les règles de Room, et les adresse déjà.

Qui fait quoi

ActorSur cette page
playercrée des Groups à partir de types déclarés, entre et sort, ajoute ou retire des membres, envoie des Events, invoque des RPC en fan-out ; s'il tient le droit d'administration sur l'appartenance d'un Group, retire des membres et le ferme
room-ownerles règles de sièges d'une Room chevauchent cette Primitive (configurées dans Rooms)
backend-servicedéclare les types de Group et leurs règles ; Hooks à l'entrée et à la sortie

En un coup d'œil

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

Le chat d'une Room est cette Primitive avec une sémantique de messages par-dessus : la Room déclare son propre type de Group, le monte dans l'espace de noms de la Room, et laisse l'appartenance propre à la Room décider qui est dedans — si bien que la liste du chat et la liste de la Room ne peuvent jamais être en désaccord.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

Rien de ce qui fait un chat n'est dans la Primitive. L'audience et la surface send.* viennent d'ici ; auteur, fil et historique viennent de Messaging, et qui a le droit d'administrer l'appartenance est un droit à part (Access), tenu par la Room.

Le modèle

Ce qu'un type de Group déclare.

DéclareCe que c'est
namecelui du type
membership modeexplicit — un membre est ajouté et retiré par une action ; ou dynamic — l'appartenance est dérivée d'une règle, et quiconque satisfait le prédicat est membre. Un Group est l'un des deux, jamais les deux
rulepour un Group dynamique : le prédicat, dans le même langage de prédicats que les prédicats d'accès et les gardes de transition. C'est la plateforme qui le recalcule ; personne ne fait de polling
capacityet le comportement lorsqu'elle est atteinte
entry ruleun prédicat qui peut rejeter l'entrée, indépendamment d'un Hook qui peut aussi la rejeter
lifecycle behaviourà la première entrée — created on first entry ou created in advance ; et à la dernière sortie — closed on last exit ou kept while empty. Déclaré, jamais inféré de l'observation
lifetimeoptionnelle : une fois expirée, le Group se ferme avec un Event

Ce qui vaut pour tout Group.

ToujoursCe que c'est
memberun Actor, jamais une Entity : un ensemble d'Entities est une sélection sur Data. Un Group est un auditeur de masse
statescreated → active → closed, et closed est terminal. Une instance de Group a une machine ; le type ne la déclare pas
event targetémettez dessus et ses membres reçoivent ; c'est ce qui fait de la livraison en masse un signal plutôt qu'une boucle
a group callest N appels, pas un : le Group fournit l'adressage et chaque issue arrive liée au membre dont elle vient. RPC exige exactement un gestionnaire logique, donc une diffusion qui attend beaucoup de réponses est N appels, pas un
partial outcomene se lit jamais comme une issue complète : un membre qui a échoué, dépassé le délai ou refusé constitue sa propre réponse portant son Problem à côté de ceux qui ont répondu ; un succès partiel n'est jamais renvoyé comme un succès entier
recipientsne sont jamais énumérés par l'émetteur : c'est l'appartenance qui décide, un émetteur n'a donc pas à connaître la composition de l'audience
intra-group rolesn'existent pas : un « propriétaire de Group » est un Actor tenant un droit (Access), pas un grade stocké dans la liste des membres
first entry and last exitsont distinguables des entrées et sorties intermédiaires — c'est à cela que tiennent le comportement de cycle de vie déclaré et l'initialisation de manche
recomputationporte le Delta : l'Event de composition d'un Group déclaré par règle dit qui est entré et qui est sorti, jamais la liste entière. La liste entière est une lecture, si bien qu'un abonné qui ne veut que le changement ne paie jamais pour la liste
join and leavesont idempotents : un client qui se reconnecte répète son entrée, obtient la même appartenance et aucune erreur — le code client n'a jamais à distinguer « je suis déjà dedans » de « je n'ai pas le droit d'entrer »
the interfaceest celle d'un Group concret, pas seulement du type : vous adressez cette escouade
the primitivereste vide : les règles d'entrée, l'initialisation de manche au premier membre, l'interception d'Events — ce sont les modules bâtis dessus. Une Room est un Group avec des règles de sièges, une conversation Messaging un Group avec des règles de livraison, un pool Matchmaking un Group que le matcher vide, une liste de diffusion un Group sans règle du tout

Erreurs

  • Une règle qui dit non et un Hook qui dit non sont des réponses différentes. Une règle d'entrée fausse se lit « entrée impossible » ; un rejet de Hook porte la raison et le code propres au Hook. Un Hook d'entrée que l'on ne peut pas atteindre refuse l'entrée — le contrôle échoue en se fermant plutôt qu'en laissant passer l'Actor.
  • L'appartenance dynamique refuse les retouches à la main. Add ou Remove sur un Group déclaré par règle est un refus de validation : le prédicat est la seule chose qui déplace cette liste, et la plateforme le recalcule quand les données derrière lui changent.
  • Quatre droits, dont aucun n'en implique un autre — entrer, administrer l'appartenance, publier vers le Group, lire la composition (Access). Un Actor à qui il en manque un reçoit un refus, jamais un silence sans effet ; un Group que lui cache un prédicat de visibilité répond « not found » à la place, et un membre voit toujours sa propre appartenance même quand la composition lui est fermée.

Limites

  • Plein est un conflit, pas une affaire de droit. À capacité + 1, l'entrée est rejetée comme conflit — l'Actor était autorisé, le siège ne l'était pas — et le même appel réussit dès qu'un siège se libère. La capacité elle-même est sur le type (Capacity = 4 ci-dessus) ; combien de Groups un Project et un seul Actor peuvent tenir est fixé avec le chapitre des limites de la plateforme.
  • Un appel de Group surdimensionné est refusé en entier, avant tout envoi — un fan-out n'est jamais livré à moitié, aucun appelant n'a donc à détecter ce cas. Le plafond de taille arrive avec le chapitre des limites de la plateforme.

Parcours utilisateur

Un groupe se forme, un appel de ralliement atteint chaque membre, un RPC en fan-out rapporte une réponse par membre, et l'escouade fait la queue comme une unité.

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Extensibility

Chaque scénario de la plateforme est une chaîne de fonctions enregistrées. Remplacez un maillon ou enveloppez-le. Voilà ce que « plateforme personnalisable » veut dire concrètement, et c'est ce qui tient lieu d'open source : vous remplacez les étapes propres à la plateforme par les vôtres, vous n'avez donc pas besoin de notre source.

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Une étape de la plateforme doit exécuter votre logique — déclarez le remplacement d'un maillon nommé avec [Override(…)].
  • Vous avez besoin de contrôles ou d'effets de bord autour d'une étape — un intergiciel Before/After ordonné, capable de veto ou de notification.
  • Du code doit s'exécuter sur une planification, un Event, ou un webhook — les déclencheurs vous remettent un contexte analysé et typé.
  • Vous devez savoir ce qui s'exécutera réellement avant le déploiement — simulez une chaîne à blanc et lisez l'ordre résolu.
  • Passez votre chemin quand la règle porte sur les écritures d'une seule Entity — un Hook Data est la forme plus légère.

Qui fait quoi

ActorSur cette page
backend-serviceredéfinit des maillons, enveloppe des étapes d'intergiciel, écrit des gestionnaires de déclencheurs
operatorinspecte les chaînes, fixe l'ordre, lit les secrets, simule la résolution à blanc

En un coup d'œil

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.

Le modèle

Ce que la plateforme vous donne pour vous y attacher.

TermeCe que c'est
registered functionune étape redéfinissable de la plateforme — « créer un profil », « résoudre un prix »
scenariola chaîne ordonnée qu'exécute un flux de plateforme : authentification, entrée, achat
overridabilitysi un maillon peut être remplacé, seulement enveloppé, ou est figé
middlewareun gestionnaire pré/post ordonné autour d'un maillon
triggerce qui démarre votre code : un Event, une planification, un webhook
secretune valeur que votre gestionnaire a le droit de lire
invocationune exécution, avec sa trace
A scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

Ce qu'un Hook déclare.

DéclareCe que c'est
positionl'étape nommée à laquelle il s'attache
kindgatekeeper — un contrôle d'admission ou une validation, et il échoue en se fermant, si bien que l'étape ne s'exécute pas quand le Hook lui-même casse ; ou observer — un journal, une notification, un compteur, et il échoue en s'ouvrant, l'étape s'exécute, et l'échec est tout de même signalé plutôt qu'avalé. Il n'y a pas de valeur par défaut
momentbefore — avant la validation, recevant la charge utile typée, et il peut muter ou rejeter ; ou after — une fois l'étape validée, recevant la requête et le résultat, effets de bord seulement, et il ne peut jamais faire échouer l'opération ni modifier la réponse
effectce que le Hook fait, et pas seulement où il se place. C'est ce qui transforme « lequel de ceux-ci s'exécute en premier » d'une bataille de nombres en un énoncé sur le travail, et c'est pourquoi l'ordre survit à quelqu'un qui ajoute un Hook à côté du vôtre
version and conditionun gestionnaire qui s'applique à un seul Environment ou à une seule audience est une version déclarée de ce Hook plutôt qu'une branche dans son corps, et c'est ce que bascule l'interrupteur du panneau

Trois façons d'attacher du code.

FormeÀ employer pour
attribut [Before(Step)] / [After(Step)]une règle sur une étape nommée — la plupart des Hooks
attribut [Override(Link)]remplacer purement et simplement l'implémentation d'un maillon
Extend.Scenario("…").Before("link", fn, order: n)envelopper un maillon à l'intérieur d'une chaîne, quand l'ordre face aux autres intergiciels compte
ToujoursCe que c'est
all threese déploient avec playserv push
the two attribute shapessont ce que le panneau rend, parce que la Declaration porte le nom de l'étape ou du maillon dans le modèle poussé
the middleware formporte un ordre à la place, ce dont une chaîne a besoin
assigning at startup(Scenario.OnX = fn) reste disponible pour un gestionnaire qui n'a pas besoin d'apparaître dans l'arbre d'administration
replacing one linklaisse les maillons de part et d'autre intacts, et ni l'un ni l'autre ne sait quelle implémentation a répondu — l'étape propre à la plateforme ou la vôtre

Où va un appel, et ce qui vaut pour toute route.

ToujoursCe que c'est
four directionsune fonction cloud · le backend externe du consommateur · le serveur de jeu · un autre, déclaré
the routerest piloté par messages/signaux ; request-response en est un adaptateur plutôt que sa nature
matchingse fait sur le nom déclaré de l'opération ou du signal et sur rien d'autre : ni la forme de la charge utile, ni l'appelant, ni la charge
a name registered twiceest un défaut de la Declaration, refusé au moment où l'ensemble est déclaré plutôt que résolu au moment de l'appel
an unregistered namerépond « not found », plutôt que d'être abandonné en silence
the directionne fait pas partie du contrat de l'opération : déplacer un gestionnaire d'une direction à l'autre n'est pas un changement cassant
"the game server"est défini par ce qu'il est, pas par qui l'héberge — notre flotte et l'hébergement propre d'un studio sont une seule direction, et la Declaration ne porte aucune marque de qui possède l'infrastructure
game-server RPCss'enregistrent dans le même routeur : en déclarer un, c'est l'enregistrer, et il n'y a pas de seconde façon
orderingexécute l'intergiciel de haut en bas, et là où une étape a plus d'une implémentation, le routeur choisit de gauche à droite par condition, la version marquée par défaut répondant quand rien n'a correspondu

Ce qu'est une contrainte entre Hooks, et quand elle est vérifiée.

Ce que c'est
a named constraintun point d'extension peut nommer les effets qu'il contraint — un contrôle anti-triche doit précéder un placement, un reçu exige un débit à ce point — et ne contraindre rien d'autre
an unnamed effectest non contraint, pas refusé : un consommateur qui fait quelque chose que personne n'avait prévu est précisément ce à quoi sert le mécanisme, et un vocabulaire fermé transformerait cela en un rejet à l'enregistrement
a violationest un défaut de la configuration, et le refus nomme les deux Hooks et la contrainte qu'ils ont brisée — pas un avertissement, et pas une réorganisation silencieuse
when it is checkedà chaque acte pouvant changer ce qui s'exécute à un point : enregistrer, déployer, modifier l'agencement. Un agencement qui parvient à l'exécution a donc déjà été admis
never re-checked at run timece serait une seconde réponse à une question tranchée, posée au seul moment où l'on n'y peut plus rien
ToujoursCe que c'est
handlerssont typés en entrée et en sortie : pas de dynamic, pas de sacs de contexte. Le handle de la plateforme est ambiant, et le contexte d'invocation — appelant, déclencheur, trace — arrive analysé
what an engine build seesles Events que le scénario émet ensuite, parce qu'une redéfinition ou un intergiciel s'exécute sur la plateforme et qu'un runtime de moteur n'est pas un endroit pour en héberger un. C'est ce que veulent dire les onglets @na des exemples de cette page en parlant de s'abonner aux Events résultants
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.

Prenez-le, modifiez-le, livrez-le. « L'open source propriétaire » est un flux de travail, pas un slogan — la logique propre à la plateforme est faite de fonctions que vous pouvez récupérer, éditer et redéployer :

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

La personnalisation ici court sur un seul axe, et c'est la logique : les overrides, le middleware et les versions dont parle cette page.

L'autre axe n'existe pas. Vous ne pouvez pas ajouter vos champs à une Entity de la plateforme. Un joueur, une Room, un enregistrement de Leaderboard et une commande sont de l'état système avec leurs propres machines, pas les prémices de votre modèle de données. Vos données sont votre propre Entity, déclarée dans Schema as Code et liée à l'état de la plateforme par un prédicat — le propriétaire est ce joueur, la portée est cette Room — ce qui est aussi ce qui les garde vôtres quand le modèle propre à la plateforme bouge.

Erreurs

  • Un nom qui ne correspond à aucun gestionnaire enregistré répond not found — un appel n'est jamais abandonné discrètement parce que personne n'écoutait.
  • Un nom enregistré deux fois, et un ensemble avec deux implémentations revendiquant la même condition, sont refusés au moment où l'ensemble est déclaré — au déploiement, et non résolus à pile ou face au moment de l'appel.
  • Le refus d'un Hook porte le code et la raison propres au Hook, si bien que « une règle du jeu a dit non » n'arrive jamais avec l'air d'un échec de transport.
  • Un Hook qui échoue se comporte selon son genre déclaré — fail-open ou fail-closed — et lequel des deux a été déclaré plutôt qu'inféré de ce qui s'est passé.
  • Un Hook n'a pas le droit de changer ce qui est déjà revendiqué : ni le propriétaire, ni la cible, ni le tableau ou la conversation à laquelle un appel était adressé. Il corrige des entrées et rend un verdict.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • L'échéance d'exécution d'un Hook — au-delà, le comportement d'échec que son genre déclare.
  • Les Hooks sur une même position, et les implémentations d'une même méthode — en enregistrer un de plus est refusé.
  • La profondeur d'imbrication de « un Hook invoque une opération qui a des Hooks » — un refus déclaré, jamais un épuisement de ressources.
  • La taille du contexte passé à un Hook — la troncature est interdite, l'enregistrement est donc refusé plutôt qu'un gestionnaire reçoive la moitié d'un contexte.

Parcours utilisateur

Un achat, du clic du joueur à travers la chaîne personnalisée jusqu'au ping de l'escouade. fraud-check est le gestionnaire propre au studio sur Before("grant"), pas un module de la plateforme ; Commerce dessine le même achat depuis son propre côté.

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

Une chaîne avec redéfinitions et intergiciels peut être résolue et lue avant que quoi que ce soit ne s'exécute. L'ordre résolu est inspectable dans le panneau et depuis le code.

Leçons et recettes : un Leaderboard dans Tanks emploie les Hooks de ce module ; un tournoi quotidien passe par ce module.

Your game's model

Schema as Code

Déclarez le modèle en code, poussez-le, récupérez des types. La voie du développeur vers le schéma : le panneau d'administration et le code écrivent le même modèle, et la codegen boucle la boucle pour chaque moteur.

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

Quand l'utiliser

  • Votre modèle de données doit vivre dans le code et se relire comme du code — déclarer, schema diff, schema push.
  • Les types du moteur ne doivent jamais dériver du modèle déployé — schema codegen régénère le C++ Unreal et le C# Unity.
  • Un changement cassant doit être lisible et annulable avant de s'exécuter — proposer → planifier → appliquer.
  • Vous réutilisez un même paquet (Stat, Interactable) d'un Project à l'autre — déclarez-le une fois comme preset.
  • Passez votre chemin quand un opérateur ne fait qu'ajuster des valeurs dans le panneau d'administration — le changement de modèle se retrouve tout de même dans le diff vers le code.

Qui fait quoi

ActorSur cette page
schema-authordéclare Entities/parts/enums en code, compare et pousse
operatorrelit la vue d'ensemble du panneau, propose et applique les migrations
cile pipeline de build, s'exécutant sous une clé backend-service : pousse à la fusion et régénère ensuite les types du moteur

En un coup d'œil

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # local declarations vs deployed schema
playserv schema push       # with a revision precondition — no blind overwrites
playserv schema codegen    # regenerate Unreal C++ / Unity C# types

Le push est une étape explicite. Rien n'est téléversé quand vous enregistrez un fichier : une Declaration n'atteint le modèle déployé que lorsque playserv push (ou schema push) s'exécute, depuis votre machine ou depuis la CI, et il porte la Revision contre laquelle il a été comparé. Une retouche faite dans le panneau vous est visible comme n'importe quelle autre dérive — schema diff la montre face à vos Declarations. La voir est automatique ; la déplacer dans un sens ou dans l'autre est une commande que vous lancez exprès.

Le modèle

Ce que porte une Declaration.

PorteCe que c'est
keyle nom stable par lequel elle est adressée. Un renommage dans le code est un renommage, pas une suppression suivie d'une création
kindune Entity, une part ou un enum, déclarés en code
ownership modeseed — le code crée l'enregistrement s'il est absent et un push répété laisse les valeurs tranquilles, si bien que la console d'administration les possède ensuite ; ou managed — le code le possède toujours, chaque push ramène les valeurs à ce qui est déclaré, et les retouches faites depuis la console d'administration sont refusées plutôt qu'appliquées puis perdues au push suivant
presetoptionnellement : un paquet réutilisable — un preset d'Entity tel que stats ou world-objects — déclaré une fois et appliqué comme un type. Il n'introduit aucun genre nouveau de Declaration, et un preset qui en aurait eu besoin serait un trou dans le contrat plutôt qu'un preset plus gros

Ce qu'un push promet.

ToujoursCe que c'est
matchingse fait par clé, jamais par symbole : un push répété après renommage du symbole laisse un enregistrement plutôt que deux
idempotencyen découle — un push répété n'est pas un second enregistrement
the reportdit exactement ce qui va changer avant l'application, et ce qu'il a écrasé ensuite
originest distinguable : un enregistrement créé par un push depuis le code se distingue d'un enregistrement créé ailleurs
the revisionvoyage avec lui, et un push atterrit en entier ou pas du tout

Ce que la codegen promet.

ToujoursCe que c'est
regenerationa lieu après chaque push, et les types générés ne sont jamais édités à la main : régénérer puis comparer ne produit aucun changement
namingsuit la Declaration où qu'elle ait été écrite — le champ Rarity d'Item devient UPSItem::Rarity dans Unreal et Item.Rarity dans Unity
the two directionsne bifurquent pas : tout ce qui est déclaré en code apparaît dans le panneau, et tout ce qu'un opérateur écrit dans le panneau se compare proprement au code — c'est ce qui fait de schema diff une réponse complète plutôt qu'une moitié de réponse

Ce qu'une migration promet.

ToujoursCe que c'est
when one is requiredun changement de Declaration persistante qui réécrit des valeurs existantes, et un changement persistant cassant ne peut pas être publié sans elle
what it declaresune version, un aperçu, une application ordonnée, un retour arrière en cas d'échec, et une issue d'achèvement observable
coexistencependant que deux versions de données sont en vie, les lectures et les écritures déclarent quelles versions elles acceptent — le runtime n'infère jamais la compatibilité à partir des noms de champs

Erreurs

  • Un appelant sans le rôle obtient forbidden et le modèle déployé n'est pas touché : un refus n'est jamais un push partiel. C'est un refus différent d'une Revision périmée, qui est precondition_failed et signifie que le diff a été calculé contre un schéma qui a bougé depuis — recomparez et poussez à nouveau.
  • Une valeur hors d'une borne déclarée est refusée à l'écriture, jamais rabotée, et une chaîne au-delà de sa longueur déclarée de même. Raboter produit une valeur valide et fausse, et le coût retombe sur le support plutôt que sur l'appelant : un refus coûte un aller-retour.
  • Une séquence UTF-8 invalide est rejetée à l'écriture plutôt que réparée.
  • Un changement persistant cassant sans migration déclarée ne peut pas être publié du tout.

Limites

Les plafonds de forme déclarative sont vérifiés au moment de la Declaration — au déploiement ou à la publication — plutôt qu'à la première utilisation, partout où le symptôme à l'exécution n'aurait pas l'air d'un refus. C'est la règle qu'énonce le chapitre des limites de la plateforme, et c'est pourquoi un schéma qui est livré est un schéma qui rentre déjà. Les nombres eux-mêmes arrivent avec ce chapitre.

Parcours utilisateur

Un nouveau champ, de sa Declaration en code jusqu'aux types de moteur régénérés.

Your game's model

Entity

Le module sur lequel tout le reste s'appuie. Une Entity est une déclaration de schéma augmentée d'aspects vivants : data 0..*, états 0..*, RPC 0..*, Events 0..*, Hooks, et historique des changements. Les cartes lient les obstacles à des Entities, la collision lie un aspect de transformation, les Stats sont un preset, les objets de monde sont un preset plus une machine à états.

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity se monte à la racine, si bien que room.Entity<Door>(id) et playserv.Entities<KeyDef>() se posent directement sur la racine plutôt que derrière un espace de noms. Il s'appuie sur trois Primitives — Events, RPC et Data — et sur rien d'autre. Collision, Locomotion et Prediction se tiennent au-dessus : chacun se lie à un aspect, pas à l'Entity entière, et c'est pourquoi un contact peut déclencher une transition sans que le module de collision sache quoi que ce soit des permissions.

Quand l'utiliser

  • Un objet du monde a besoin de comportement, pas seulement de champs — machines à états, RPC soumis à permission et Events sur une seule Declaration.
  • Portes, pièges, ramassages : les transitions doivent se déclencher depuis des Events de client, des contacts de Collision, ou des seuils de Stat sans code de Room.
  • Vous voulez des objets de jeu comme créations d'une ligne — appliquez ou dérivez des presets comme world-objects.
  • Un litige exige l'état exact du monde au moment du tir — lisez une instance à un sim_time passé, dans la fenêtre déclarée.
  • Passez votre chemin quand la chose n'a pas d'identité — une valeur qui ne vit jamais qu'à l'intérieur d'autre chose, comme le texte sur la plaque d'une porte, est un champ dans un aspect, pas une Entity à part entière. Tout ce qui est adressé est une Entity : Data est la mécanique du dessous, et aucun chemin de table ne la contourne.

Qui fait quoi

ActorSur cette page
schema-authordéclare les Entities, les aspects, les machines à états, les presets
every actorinterroge, s'abonne, appelle les RPC d'Entity, lit l'état

En un coup d'œil

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

L'aspect est l'unité déclarée, pas le champ : motion porte sa propre cadence et son propre masque, info en porte d'autres, et un champ appartient à exactement l'un d'eux. C'est ce qui permet à un preset d'attacher tout un groupe d'un coup, et ce qui permet à Collision de se lier au seul aspect qui porte une transformation sans rien voir d'autre sur l'Entity.

Trois règles régissent la machine de ce bloc :

  • Un nom pointé imbrique d'un niveau. open.blocked s'associe à open de lui-même, si bien que la transition close_requested déclarée sur open s'applique à l'intérieur sans être répétée. Tant que la machine est dans open.blocked, elle est dans open — une vérification d'état pour open est vraie, et OnEntered("open") s'est déclenché à l'entrée et ne se redéclenche pas pour le sous-état.
  • Un minuteur déclaré sur un état ne tourne que tant que cet état est le courant. Quitter opening abandonne son AfterSeconds, et y entrer de nouveau en démarre un neuf.
  • Une garde est un prédicat déclaré sur les champs propres de l'Entity, dans le même langage qu'un prédicat de ligne d'accès. La logique qui a besoin de code est un Hook, pas une garde.

Les chemins de champs sont la graphie du modèle poussé. Les prédicats et les chemins de requête nomment les champs comme la Declaration les a poussés — motion.jammed, gate.state, info.name — quelle que soit la façon dont chaque binding les écrit localement.

Qui peut appeler le RPC. Un RPC d'Entity nomme le droit dont il a besoin comme toute operation : un atom de droit (entity × execute) plus un prédicat de ligne disant quelles instances il couvre (Access & Roles possède les deux).

Dans la DeclarationCe que cela veut dire
caller in entity.roomn'importe quel Actor dans la Room où se trouve la porte, quel que soit le build qu'il fait tourner. La proximité n'en fait pas partie : à quelle distance il faut se tenir pour recevoir les deltas de la porte est une règle de Visibility sur la politique de sync de l'aspect — de la bande passante, pas une permission, et élargir une vue n'élargit jamais un droit
Actorl'identité de l'appelant, le même objet que renvoie whoami
caller.Inventoryle handle d'Inventory pour ce joueur, disponible partout où ce module est monté

C'est playserv push qui rend une Declaration réelle. Schema as Code possède l'étape : il compare vos Declarations au modèle déployé, porte la Revision contre laquelle il a été comparé, et refuse au lieu d'écraser si le schéma déployé a bougé. Un nouveau push qui casserait des instances déjà en vie passe par proposer → planifier → appliquer, si bien que le plan est lisible avant que quoi que ce soit ne change.

Sur le client, l'Entity est l'API :

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

Le signal locked est l'Event propre à la porte : sa cible est celle de la Declaration — cette instance — si bien que tous ceux qui sont abonnés à la porte l'entendent et qu'aucune liste de destinataires ne voyage avec l'envoi.

Le modèle

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

Un tank, une porte, une barre de Stat et une quête sont tous des Entities. Ils diffèrent par les aspects qu'ils portent et par rien d'autre, et c'est ce qui permet à tous les autres modules de bâtir sur celui-ci.

Ce qu'une Entity déclare.

DéclareCe que c'est
aspectun groupe de champs nommé déclaré d'un bloc, avec sa propre politique de synchronisation et son propre masque d'accès. Une Entity en porte plusieurs, et aucun champ n'est dans deux
state machinedes états imbriquables d'un niveau, des transitions et des gardes ; plusieurs par Entity
triggerce qui déclenche une transition — les quatre sources sont ci-dessous
entity RPCun verbe qui dépasse de l'Entity, déclaré à l'intérieur de la vue avec l'atome de droit dont il a besoin
entity eventun signal que l'Entity émet, livré à quiconque s'abonne à cette instance
hookavant et après, sur les opérations de données et sur les transitions, déployés comme fonctions cloud. Extensibility déclare l'ordre, la forme du verdict et ce que fait un échec
history tracksi la vue garde ou non la fenêtre instantanée
refun lien vers une autre Entity tenant son id et jamais sa key, si bien que renommer une clé ne casse jamais un lien. Include la ramène avec la page

Ce qui déclenche une transition, et aucune des quatre n'est votre code s'exécutant dans une Room.

SourceComment cela se déclenche
client event or RPCn'importe lequel de ceux déclarés — RequestOpen ci-dessus déclenche open_requested
collisionun contact ou une entrée dans un volume déclencheur — pièges, plaques de pression — à travers l'aspect auquel Collision se lie
data thresholddéclaré sur un Stat, 0 HP → death, appliqué par l'ordre des Hooks plutôt que par du code dans une Room
timeAfterSeconds sur un état est un déclencheur déclaré, pas une coroutine : il tourne sur l'horloge de simulation de la Room, avance avec sim_time, s'arrête pendant que la Room ne simule pas, et supprimer l'instance met fin à ses machines et à leurs minuteurs en attente avec elle

Ce qu'une sélection a le droit de faire.

AxeCe qui est admissible
filter et sortdes champs déclarés seulement — il n'y a pas de handle de table, et une sélection est adressée par Entity, bornée à une Room ou au Project
includeun ref déclaré, ramené avec la page
pagingpar curseur opaque : pas un décalage, pas un id de ligne, et son sens ne survit pas à un changement de version. Repassez-le, ne l'analysez jamais
accessles prédicats s'appliquent avant la pagination, si bien qu'une page ne porte jamais de trous là où seraient les lignes cachées
lives'abonner à une sélection la garde vivante, avec des membres qui entrent et sortent à mesure que leurs données changent
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

Ce qui vaut pour toute Entity.

ToujoursCe que c'est
a selectionest un ensemble d'Entities, pas un Group : les membres d'un Group sont des Actors et existent pour qu'un seul signal les atteigne tous, tandis qu'une sélection est une lecture qui se trouve rester vivante
a transition requestreste une demande : les gardes de la machine s'exécutent, le prédicat de ligne s'exécute, et une transition que la machine ne déclare pas est refusée avec invalid_state_transition plutôt qu'ignorée en silence
pushing past a guardest une opération différente avec un atome différent — entity × administer, qu'aucune clé cliente ne tient par défaut
historyest la fenêtre instantanée seulement : les états récents indexés par sim_time, bornés par une profondeur déclarée, et une lecture en dehors est refusée plutôt que répondue par la valeur la plus proche. Un historique ramifié — chronologies alternatives, annulation, rejeu d'une partie entière — est hors périmètre, parce qu'il devrait promettre des valeurs flottantes reproductibles et que les règles de types ne le font pas
the boundary of a changeest une Entity, et c'est là que « tout ou rien » s'arrête. Deux Entities modifiées par un même appelant — débiter un portefeuille, ajouter l'objet — peuvent être observées à moitié appliquées. Une paire qui doit apparaître ensemble n'est donc pas deux Entities : gardez les deux valeurs dans une même instance et la frontière fait le travail. Recourir à un Hook pour « rendre cela atomique » ne le fait pas, parce que le Hook s'exécute autour d'un changement plutôt qu'à travers deux
a declared method with no implementationest un état achevé, pas un état à moitié configuré. L'appeler répond par un verdict portant la raison lisible par machine « pas d'implémentation » — pas un refus, et pas un succès tenant un résultat vide. Un refus voudrait dire que l'appel n'aurait pas dû être fait ; ici il aurait dû l'être, et la seule chose qui n'a pas eu lieu est la décision
a name never declaredest une issue différente d'un nom déclaré sans implémentation : le premier est un refus de validation, le second un verdict, et le code peut les distinguer
an unimplemented callne disparaît pas — que quelqu'un l'ait invoqué est observable pour le studio. La forme que prend l'observation ne fait délibérément pas partie du contrat : bâtissez sur le fait que c'est observable, pas sur une ligne de journal
creating an instanceporte l'atome entity × write : une fonction cloud, un serveur dédié et un master-client le tiennent par défaut, et un client ordinaire seulement là où un rôle l'accorde — dans chaque binding, pas seulement dans Unreal

Presets. Un preset est un paquet nommé d'aspects, de machines, de Hooks et de limites appliqué à une vue. Il n'ajoute aucun concept nouveau — tout ce qu'un preset apporte, vous pourriez le déclarer à la main, et c'est pourquoi un preset qui exige un genre nouveau de Declaration est un trou dans le modèle plutôt qu'un preset plus gros. Cinq sont livrés : stats, abilities, projectiles, drops, world-objects, et Entity presets déclare chacun en entier. Un studio en dérive les siens, en code ou dans le panneau — Crate est world-objects plus stats :

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Erreurs

  • Une instance qui n'existe pas, et une qu'un prédicat cache, répondent toutes deux not found — un refus ne dit donc jamais à un appelant que quelque chose existe mais ne lui appartient pas.
  • Un champ non déclaré, imbriqués compris, et un champ obligatoire sans valeur, sont des refus de validation nommant le champ.
  • Une transition que la machine ne déclare pas est refusée comme invalid_state_transition, jamais ignorée en silence. Pousser une machine au-delà de ses gardes est une opération différente avec un atome différent — administer, qu'aucune clé cliente ne tient par défaut.
  • Un désaccord de version est un échec de précondition : relisez et décidez à nouveau.
  • Une clé déjà prise est un conflit.
  • Une écriture pour le compte d'un joueur qui ne nomme pas le joueur est un refus de validation plutôt qu'une écriture attribuée à personne.
  • Pas de permission répond forbidden, avec lecture et écriture distinguées.
  • Une lecture d'historique en dehors de la fenêtre instantanée est refusée plutôt que répondue par la valeur la plus proche — « pas de données pour ce Tick » et « voici à peu près la valeur » sont des faits différents.
  • Une instance stockée au-delà de la limite de taille est un conflit nommant le champ fautif et la taille mesurée ; le plafond est atteint par accumulation, si bien que l'approche est observable avant l'écriture qui échoue.

Limites

Chaque limite est déclarée avec ce qui se passe à son bord. Les nombres sont par Project et fixés par Project ; le comportement ci-dessous est fixé dès maintenant.

LimiteAu bord
aspects par vue · machines par vue · profondeur d'imbrication dans un aspectla Declaration est rejetée à playserv push, jamais tronquée en silence
taille d'instance stockéel'écriture est refusée comme conflit, nommant le champ et la taille mesurée ; l'approche de la limite est observable avant le refus
taille de page d'une sélectionla page est coupée au plafond et « il y en a d'autres » reste vrai — vous n'obtenez jamais une page courte qui a l'air finale
fenêtre d'historique instantanéune lecture en dehors de la fenêtre est refusée, pas répondue par la valeur la plus proche
cadence de changement sur une instanceun refus de limite de débit portant le temps d'attente

Parcours utilisateur

Une porte, de sa Declaration jusqu'au carillon que le joueur entend.

Your game's model

Héritage et composition

Les modules s'appuient les uns sur les autres, et rien de tout cela n'est du sous-classement. Il n'y a pas de module de base dont dériver ni de hiérarchie à étendre — les modules forment un graphe. Cette page dit ce que « héritage » veut honnêtement dire ici, et les six mécanismes qui font le travail à la place.

Ce que « héritage » veut dire ici

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

Le mot recouvre quatre mécanismes différents, et il vaut la peine de les nommer séparément.

  • Les RPC d'une Entity font partie de l'Entity. Ils n'existent nulle part ailleurs — pas sur un quelconque parent, pas dans un registre partagé. Si une méthode appartient à une porte, elle est sur la porte. Voir Entity.
  • Un preset est un paquet nommé, pas une classe de base. Stats, abilities, projectiles, générateurs de drops et objets de monde sont des presets d'entity — des paquets d'aspects qu'une vue d'Entity applique, et c'est pourquoi ils vivent sur une seule page, Entity Presets, plutôt qu'en cinq modules. Appliquer un preset ajoute des aspects ; cela ne place pas votre type sous quoi que ce soit.
  • Redéfinir une étape de la plateforme est un attribut posé sur votre remplacement. Vous ne sous-classez pas la nôtre ; vous déclarez la vôtre, et les versions sont choisies par condition avec la valeur par défaut de la plateforme en repli. Voir Extensibility.
  • Un module en emprunte un autre par un décorateur qui restreint ou enrichit l'interface empruntée, avec une implémentation interchangeable derrière. Le cas travaillé est un chat à l'intérieur d'une Room, sur Groups.

Et ce que ce n'est pas : il n'y a pas de hiérarchie de classes de modules, parce qu'un arbre n'autorise que des branches et que les vraies fonctionnalités les traversent. Le matchmaking réserve des sièges dans les Rooms ; les drops placent des objets à travers la carte ; un Leaderboard est alimenté par un Hook sur la fermeture d'une Room. C'est un graphe, et c'est délibéré.

Les six mécanismes

Chacun est déclaré par un attribut à côté de la chose qu'il compose — la même règle déclarative qui gouverne tout le reste du SDK.

Des points de montage, comme dans un système de fichiers. Un module se monte à la racine — en composant plusieurs interfaces en une seule surface — ou dans un espace de noms. Un second module qui revendique un point de montage occupé est rejeté au moment du montage, jamais au premier appel. Mécanisme sur Sous le capot.

Visibilité lexicale. La visibilité des noms suit l'imbrication : une déclaration globale est visible à l'intérieur d'un module, une déclaration locale ne fuit jamais vers le haut. Ce qu'un module émet est une question distincte et se déclare dans son propre contrat — un module ne connaît que les Events qu'il a déclarés, ou qui ont été enregistrés auprès de lui.

L'encapsulation comme contrat. Un module ne sait jamais qui l'appelle ni pourquoi. Ce qu'il expose et ce qu'il émet est toute son histoire publique, et rien de l'appelant ne change son comportement, hormis l'habilitation de l'appelant.

Réemploi par décorateur et inversion de contrôle. Un module en désigne un autre par un décorateur plutôt qu'en allant fouiller dedans, et l'implémentation derrière l'interface est interchangeable. C'est le mécanisme qui vous permet de remplacer l'un de nos modules par le vôtre sans que les modules qui en dépendent s'en aperçoivent.

Les Declarations font pousser l'API. Déclarez un Event sur un Group et group.Send.ChatMessage(…) apparaît avec son contrat ; déclarez la donnée members et un accesseur typé apparaît. La Declaration est l'entrée de la codegen — ce qui explique aussi que ce soit la Declaration, et non le code généré, que vous versionnez.

Trois axes d'adressage sortant d'un même module. Toutes les instances, une instance, et l'administrateur d'une instance sont trois API distinctes, pas une API avec un drapeau. Énoncé en entier sur Groups.

Les arguments implicites, et pourquoi ce n'est pas de la magie

À l'intérieur d'une Entity, vous ne passez jamais l'Entity. Le récepteur, l'appelant et le contexte ambiant se lient automatiquement, parce que tous trois sont déjà déterminés par l'endroit où l'appel a été fait et par qui l'a fait — les passer reviendrait à vous demander de redire quelque chose que la plateforme sait déjà, et à vous donner une occasion de le dire de travers. Le mécanisme est sur RPC.

Your game's model

Entity presets

Un preset est un paquet nommé d'aspects d'Entity — data, états, RPC, Events, Hooks — empaquetés pour un cas de jeu. Vous appliquez un preset, vous en ajustez les nombres, ou vous dérivez le vôtre. En appliquer un ajoute des aspects à votre type ; cela ne place pas votre type sous quoi que ce soit — un preset n'est pas un module et n'a rien à lui dont hériter. Stats, abilities, projectiles, tables de drop et objets de monde sont cinq presets, pas cinq sous-systèmes : la même Declaration, la même synchronisation, le même ordre de Hooks.

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

Quand l'utiliser

  • Une chose de votre jeu porte des nombres qui se bornent, se régénèrent, et déclenchent une transition à leurs bornes.
  • Une action a besoin d'un coût, d'un cooldown, de phases et d'effets, atteignables depuis un seul verbe client.
  • Quelque chose part en vol, et son impact doit être jugé équitablement pour un tireur en retard.
  • Le loot doit venir de chances pondérées qui se rejouent exactement quand un joueur conteste un drop.
  • La carte a du mobilier — portes, boutons, pièges, destructibles — avec des états qui doivent survivre à une entrée en cours de manche.
  • Passez les presets quand une Entity n'est que de la donnée synchronisée. Déclarez les champs et arrêtez-vous là.

Qui fait quoi

ActorSur cette page
schema-authordéclare les Stats, les abilities, les projectiles, les tables de drop, les objets de monde
room-ownerajuste les nombres des presets, tire les tables de drop, crée des objets de monde
playerlance des abilities, tire, ramasse du loot, interagit avec les objets

En un coup d'œil

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Le modèle

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

Un preset n'introduit aucune notion nouvelle. Tout ce qu'il ajoute est exprimable par les moyens qu'Entity donne déjà — aspects, machines, Hooks, persistance. Un preset qui aurait besoin d'un genre nouveau de Declaration serait un trou dans le contrat, pas une raison de grossir le preset. C'est là tout le test pour savoir si quelque chose a sa place ici.

Ce que donne chaque preset, et où il s'ajuste.

PresetCe que le contrat lui donneOù il s'ajuste
statsun aspect de caractéristiques numériques avec bornes, régénération et modificateurs, plus un Hook à l'atteinte d'un seuil — 0 HP devient une transition de machine plutôt qu'un if dans votre codela Declaration du champ ; les nombres restent éditables en direct dans le panneau
abilitiesun aspect d'un ensemble d'abilities, une machine de phases d'application, et un coût et un cooldownla Declaration de l'ability
projectilesun type à persistance runtime, un aspect de balistique, et un Event d'impactla Declaration du projectile — un seul échange d'attribut change le modèle de vol
dropsun aspect de table de drop avec des poids, et un Hook après la mortles entrées et les poids de la table
world objectsune machine à états d'objet interactif, et un aspect de la condition d'interactionla Declaration du preset, ou par instance à la création
inventoryun type possédé avec un ref vers un article de catalogue, un aspect de pile avec un incrément, et un plafond par propriétaire avec débordement déclaréla Declaration du type

Le tableau tient une ligne par preset parce qu'une ligne est ce qui les distingue. Ce qu'ils partagent est plus bas, et Inventory est le seul à avoir aussi sa propre page.

Ce qui vaut pour tout preset.

ToujoursCe que c'est
where it sitssur une Entity, comme aspects : ses données se synchronisent comme n'importe quelle autre donnée, ses états sont des états d'Entity, ses RPC sont des RPC d'Entity, et ses Hooks s'exécutent dans l'ordre des Hooks d'Entity
tuningest de la configuration en direct plutôt qu'un redéploiement, et c'est pourquoi le panneau montre une borne de Stat, un cooldown et un poids de drop dans un même arbre
declaring oneest un acte de schéma, pas un appel de gameplay — et c'est pourquoi son refus est d'un genre différent des refus que rencontre un joueur, et les deux sont dans « Erreurs » ci-dessous
deriving your ownest de la composition, pas du sous-classement : Crate est world-objects plus stats, et la chose dérivée reste des aspects sur une Entity

Erreurs

  • Déclarer un Stat, une ability, un projectile, une table de drop ou un objet de monde est un acte de schéma — fn ou adm. Un joueur ou une clé cliente qui tente l'un d'eux obtient forbidden, et rien n'est déclaré ni à moitié déclaré. C'est un refus différent de ceux qu'un joueur rencontre à l'intérieur d'un appel qu'il avait le droit de faire — en cooldown, incapable de payer, un item:key.bronze manquant — chacun portant son propre code.

Le reste des refus d'un preset sont ceux d'Entity — un preset n'introduit aucune notion, il n'introduit donc aucun refus non plus, et les redire ici donnerait au lecteur deux endroits à vérifier pour une seule réponse. Deux choses sont propres aux presets eux-mêmes :

  • Un preset partiellement rempli est un échec de validation au déploiement. Un preset porte un ensemble cohérent : une demi-Declaration est refusée avant d'être livrée plutôt que de se comporter bizarrement en pleine partie.
  • Un preset ne peut pas être marqué d'une propriété que sa propre mécanique contredit — un aspect dont le client n'a pas les règles ne peut pas être déclaré prédictible, et cela aussi est attrapé au déploiement.

Limites

Les plafonds sont ceux d'Entity — aspects par type, machines par type, taille de l'instance stockée, cadence des changements sur une instance. Le seul qu'un preset déclare lui-même est le plafond par propriétaire que porte un preset possédé, avec l'un de trois comportements au bord et pas de valeur par défaut : refuse · redirect vers un bac de propriétaire déclaré · discard with event. Les nombres arrivent avec le chapitre des limites de la plateforme.

Parcours utilisateur

Un obus, de la pression sur la détente jusqu'à la caisse aux pieds du tireur. Quatre presets y prennent part — ability, projectile, stat et drop-table — et aucun n'est un module que vous montez.

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Rooms

Une Room est une session de jeu ; la plateforme se moque de ce qui l'héberge. Une seule abstraction couvre un serveur dédié par partie, une grande carte partagée découpée en couches logiques, une Room hébergée par master-client, et un mini-jeu hébergé par le backend. L'intérieur d'une Room est le nôtre ; vous conduisez une Room depuis l'extérieur.

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Votre jeu a des sessions — parties, lobbies, donjons, courses — et quelque chose doit posséder leur cycle de vie, leur appartenance et leurs reconnexions.
  • Vous hébergez sur des serveurs dédiés, sur le master-client d'un joueur, ou sur le backend lui-même, et vous devez y router les joueurs.
  • Une seule carte partagée doit faire tourner de nombreuses sessions logiques — des couches, bornées par Visibility.
  • Des joueurs entrent en cours de session et doivent voir la vérité courante — l'état de la Room à l'arrivée, puis le trafic en direct.
  • Une connexion tombée ne doit pas coûter le siège — la fenêtre de tolérance du template (45 s dans battle) reprend la même appartenance.
  • Passez votre chemin quand une fonctionnalité est purement requête/réponse sur des enregistrements — Data le couvre déjà.

Qui fait quoi

ActorSur cette page
room-ownerenregistre des Rooms dans tout le processus ; sur une instance de Room — l'interface d'administration par instance : retouche la configuration en direct, expulse, verrouille, diffuse, détruit
entry-validatoraccepte ou rejette les demandes d'entrée avec un code et une raison
room-visitorparcourt, entre avec des données, se reconnecte dans la fenêtre de tolérance, sort
spectatorentre sans prendre part à la partie ; reçoit les diffusions et le trafic en direct
match-organizerréserve des sièges qui comptent dans la capacité ; une réservation expire au terme du template (90 s dans battle)

En un coup d'œil

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Où qu'il ait été écrit, le template poussé est versionné et éditable dans le panneau, si bien que le live-ops réajuste un type de Room sans redéploiement du moteur. Héberger des Rooms construites à partir de lui est la même surface atteinte par un rôle différent, et un serveur dédié Unreal comme un master-client tiennent tout cela — enregistrer, héberger plusieurs par processus, retoucher la configuration en direct, expulser, publier, détruire.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Client :

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

Le modèle

Une Room est un Group avec des règles, et ce n'est pas une Entity. L'appartenance vient des Groups ; son état système est de niveau plateforme, tandis que l'état du jeu vit dans des Entities bornées à elle. Et aucun code de consommateur ne s'exécute à l'intérieur d'une Room — dans aucun des deux modes d'autorité.

Ce qu'un type de Room déclare.

DéclareCe que c'est
capacityen sièges, et un siège est l'unité de capacité séparée de l'appartenance : il peut être réservé avant une entrée et tenu à travers l'inactivité. Une réservation est limitée dans le temps, avec une échéance déclarée après laquelle le siège est libéré sans entrée
visibilityénumérable · par nom ou par code · cachée
creation model'un de trois, et le mode on first join est obligé de déclarer un Hook d'initialisation
two independent timeoutsle délai d'inactivité — combien de temps un participant peut rester silencieux ; et le TTL de Room vide — combien de temps survit une Room où il n'y a personne. Deux questions différentes, donc deux Declarations
rejoin windowà l'intérieur d'elle, un retour restaure la même appartenance et le même siège, au lieu de créer un nouveau participant
authority modeour simulation ou external authority, et il n'y a pas de valeur par défaut
trust in a reported outcomepour l'autorité externe : l'accepter · le vérifier avec un Hook · ne pas l'accepter. Là encore pas de valeur par défaut
behaviour when the host dropsattendre l'écoulement de la fenêtre de tolérance · fermer la Room · admettre un remplaçant
map instance and world strataoptionnellement, quelle instance elle occupe et quelles strates à l'intérieur
room-scoped entitieslesquelles des Entities du studio ont la portée de la Room — exprimé par un prédicat, non par un mécanisme nouveau

Les deux machines.

DeÉtats
une Roomcreated → open → closed → torn down, où torn down est terminal et closed veut dire plus de nouvelles entrées plutôt que disparue
une appartenanceactive ⇄ inactive → departed, avec departed terminal pour cette appartenance

Ce qui vaut pour toute Room.

ToujoursCe que c'est
losing a connection and leavingsont des événements différents, et l'issue de la fenêtre est observable : « est revenu » et « la fenêtre a expiré » sont distinguables, si bien qu'un client n'en est jamais réduit à deviner lequel des deux s'est produit
no replayune reconnexion reprend depuis l'état de session ; le module ne promet pas les Events de l'intervalle
a spectatorn'est pas un participant dégénéré : présent, n'occupant aucun siège, et hors de la liste que l'on adresse comme « les joueurs » — sans quoi chaque opération sur la liste porterait une condition
no in-room rolesun propriétaire de Room est un Actor tenant un droit (Access), pas un grade dans la liste des membres
presencea un historique, la liste non : qui est entré, est tombé, est revenu et est sorti est conservé ; les changements de la liste ne sont pas un second historique
the interfaceest celle d'une Room précise : vous adressez cette Room, pas seulement son type
three axes, not twol'API qui couvre toute Room (parcourir, enregistrer, lister) ; l'API par Room que tout membre appelle (entrer, sortir) ; et une interface d'administration par instance — expulser, verrouiller, retoucher la configuration, fermer, détruire cette Room — réservée à qui détient le rôle d'admin ou d'hôte de cette instance-là plutôt qu'à l'appartenance
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

Les deux modes d'autorité.

ModeQui conduit le Tick
our simulationnotre implémentation de Room et ses modules
external authorityun processus exécutant notre SDK, dans la Room sous un rôle qui fait autorité : le serveur de jeu du studio, ou le client d'un joueur comme master-client

Ce que le mode décide, et ce qu'il ne décide pas.

Ce que c'est
the lineest tracée par rôle, non par le propriétaire du processus. Un serveur dédié est le même client sans le rendu ; ce qui le sépare de la machine d'un joueur est la confiance, pas la construction — ce qui explique aussi que le pair-à-pair n'ait besoin d'aucun troisième mode, étant une Room en mode d'autorité externe dont l'autorité est un hôte client
what is identicalles règles d'entrée, la présence, la reconnexion et chaque Declaration, dans les trois cas. Ce qui diffère est seulement quel processus tient l'autorité et quelle part lui en est accordée
the room does not moveaucun code de consommateur ne s'exécute à l'intérieur d'une Room dans aucun des deux modes, et l'état de session comme l'état persistant restent chez nous dans les deux. Un master-client est un membre tenant un rôle qui fait autorité : le Tick est calculé là, la Room n'y habite pas
trust in the outcomeest une Declaration séparée sur le type de Room — l'accepter · le vérifier avec un Hook · ne pas l'accepter, et pas de valeur par défaut — plutôt qu'une propriété du mode

Héberger une Room.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
ToujoursCe que c'est
the handleest le même objet qu'emploie l'onglet client : il n'y a pas d'amorçage d'hôte ni de handle réservé au serveur. Il répond à ces appels parce que le rôle de l'Actor les inclut à l'exécution — un serveur dédié ou un master-client s'exécutant sous une clé d'hôte (Access)
registrationprend une clé d'idempotence, parce qu'un délai dépassé dessus serait autrement irrécupérable : répétez l'appel avec la même clé et vous récupérez la même Room, pas une seconde dont personne ne connaît l'adresse

Entrée, présence et reconnexion.

Ce que c'est
entryest une demande validée : l'entrant fournit des données à l'entrée et le Hook d'entrée accepte ou rejette avec un code et une raison. Ces données sont ce à partir de quoi le membre s'initialise — dans battle, le loadout apporté à l'entrée est ce avec quoi le Tank du membre apparaît
seats and reservationsMatchmaking revendique une place pour le terme de réservation du template — 90 secondes dans battle — et le client entre ensuite directement. Les réservations comptent dans la capacité, et l'expiration libère le siège avec un Event plutôt qu'en silence
a drop is not a leaveun membre déconnecté garde son siège pendant la fenêtre de tolérance (45 s dans battle) et se reconnecte dans la même appartenance ; l'expiration en fait une sortie, et l'Event porte laquelle des deux c'était. À qui revient une seconde trop tard on dit que la Room est vivante et que l'appartenance ne l'est pas — une réponse différente de « pas de Room de ce nom », exprès
late join is state, not a journalun entrant reçoit l'état courant de la Room puis le trafic en direct. Les Events envoyés pendant son absence ne sont pas rejoués, ni ceux qu'un membre de retour a manqués : tout ce qui doit survivre à l'intervalle est de l'état — la mine qu'un joueur a posée est une Entity bornée à la Room, pas un message MinePlaced que quelqu'un doit attraper

Erreurs

  • Une Room qui n'existe pas ou qu'un prédicat cache, et une Room détruite, répondent not found — un refus ne révèle jamais une Room que vous n'avez pas le droit de voir.
  • Capacité épuisée est un conflit, et les sièges réservés comptent comme occupés ; à retenter une fois qu'un siège se libère. Fermée aux entrées est également un conflit, à retenter si elle rouvre.
  • Une entrée rejetée par une règle est un conflit ; rejetée par un Hook, elle porte le code et la raison propres au Hook, si bien que « une règle du jeu a dit non » n'arrive jamais avec l'air d'un échec de transport.
  • La fenêtre de retour a expiré est un conflit : entrez comme nouveau participant, avec un nouveau siège.
  • Une réservation périmée est un conflit : prenez-en une autre.
  • Une instance de carte indisponible ou inexistante est un refus de validation.
  • Un déplacement que la Room de destination a rejeté est un conflit, et quoi faire dépend de sa raison.
  • Une création au-delà de la limite de Rooms répond comme limite de débit ou comme conflit selon la limite dont il s'agissait.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • La capacité d'une Room — une entrée est refusée comme conflit, les sièges réservés comptés comme occupés.
  • Rooms par Project — la création est refusée comme conflit.
  • Rooms par Actor — la création est refusée, et les Rooms déjà créées ne sont jamais détruites pour faire de la place.
  • La cadence de création de Rooms — une limite de débit avec une échéance.
  • Le délai d'inactivité d'un participant — un départ forcé avec un Event et une raison déclarée.
  • Le TTL de Room vide — destruction avec un Event ; désactivable sur le type de zone persistante.
  • L'échéance de réservation d'un siège — libération avec un Event.
  • Rooms sur une même instance de carte — créer sur une instance occupée est refusé à moins que le type n'ait déclaré une occupation partagée.
  • La taille de la charge utile d'un Event de Room — la publication est refusée avant l'envoi, jamais tronquée.

Parcours utilisateur

Une partie sur un serveur dédié, de la connexion du joueur jusqu'au HUD montrant qui est entré.

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

Qui voit quoi, et quelle machine l'exécute

Deux questions qui sonnent comme une seule. Qui voit quoi concerne un client : quelle tranche de l'état de la Room parvient à quel joueur. Quelle machine l'exécute concerne un host : quel processus possède une Entity, et lequel la possédera ensuite. Le mot qui les confond est replication : dans un moteur de jeu il nomme d'ordinaire la première, et ici il nomme la seconde.

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
Si vous voulez direLisez
quel client reçoit quel état, et quelle quantitéVisibility, avec Data et Prediction
quelle machine possède l'Entity, et ce qui arrive quand elle meurtWhat Survives Losing a Host

Elles se déclarent à deux endroits différents

Ni l'une ni l'autre ne se configure à l'exécution, et elles ne partagent pas de Declaration.

Déclaré surQui nomme
qui voit quoil'aspect — Data, Visibilityle prédicat de visibilité, le plafond d'objets et son ordre, quelles zones voisines sont visibles, et le mode de livraison
quelle machine l'exécutele type de Room — Roomsle mode d'autorité, jusqu'où une autorité externe est crue sur une issue, et le comportement quand le host tombe

Elles diffèrent aussi par ce qui arrive si vous ne dites rien. Un aspect sans règle de visibilité propre est livré dans le paquet partagé, ce qui est la valeur par défaut et convient à une petite Room. Un type de Room qui ne nomme aucun mode d'autorité est refusé : il n'y a pas de défaut, parce que rien ne peut choisir à votre place entre notre simulation et une simulation externe.

The live game

Visibility

À 40 joueurs, un snapshot de Room entière convient. À 200, non. Une zone de visibilité décide qui reçoit quoi, sous forme de prédicat déclaré plutôt que d'un interrupteur que vous basculez par objet. La diffusion et les paquets par Actor sont deux modes de livraison déclarés d'un seul modèle, si bien que passer de l'un à l'autre est de la configuration plutôt qu'une réécriture. C'est une optimisation de Channel et non une permission — pour cela, voir Access.

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

Un seul modèle déclaré — le prédicat, les couches, les paliers de détail — se lit de deux façons. Passer de l'une à l'autre est de la configuration, pas une réécriture, parce que les deux sont des lectures de la même Declaration.

DiffusionPaquets par Actor
Envoietoute la Room, à tout le mondeà chaque joueur seulement la tranche que ses règles sélectionnent
Convient àune petite Room ; c'est la valeur par défautune foule, où la taille des paquets doit rester prévisible
Lit la Declarationune fois, pour la Roompar Actor

Ce qui ne doit pas fuiter est absent du paquet plutôt que caché sur le client — jamais envoyé, ce qui en fait une propriété de sécurité et non de bande passante.

Quand l'utiliser

  • Vos Rooms dépassent la diffusion de Room entière — 200 joueurs ont besoin de flux de voisinage par client, pas de chaque Delta.
  • L'état ne doit pas fuiter : le brouillard de guerre et les champs réservés au propriétaire ne doivent jamais être envoyés, pas être cachés côté client.
  • Plusieurs sessions partagent une carte et ne doivent pas se voir — une couche est un prédicat de plus.
  • La taille des paquets doit être prévisible dans une foule — plafonnez les objets et déclarez l'ordre, pour que « les N plus proches » soit une promesse et non un accident de densité.
  • Un joueur à une frontière doit voir au-delà — déclarez quelles zones voisines sont visibles, parce que par défaut il ne voit que la sienne et qu'une frontière se lit sinon comme un mur de vide.
  • Passez votre chemin quand la Room est petite — le mode de livraison en paquet partagé la couvre déjà.

Qui fait quoi

ActorSur cette page
schema-authordéclare le prédicat de visibilité, le plafond d'objets et son ordre, quelles zones voisines sont visibles, et le mode de livraison
anys'abonne et reçoit ce que la zone admet ; peut abaisser le plafond d'objets pour lui-même dans les bornes déclarées

En un coup d'œil

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

Le modèle

Ce qu'une règle de visibilité déclare.

DéclareCe que c'est
predicatela règle elle-même, dans le même langage de prédicats que les prédicats d'accès et les gardes de transition. Un rayon, une instance de carte, une équipe et la propriété sont des cas particuliers d'un prédicat, pas des mécanismes distincts — il y a exactement un tel langage dans le contrat
object cap et son ordreune règle peut plafonner le nombre d'objets, et alors l'ordre de sélection est déclaré plutôt qu'inféré : « les N plus proches » est un prédicat plus un tri par distance plus un plafond. Un prédicat seul ne peut pas l'exprimer, parce qu'un prédicat répond « cette ligne se qualifie-t-elle », pas « laquelle des qualifiées est la plus proche »
neighbouring areassi les objets d'une Room ou d'une instance de carte voisine sont visibles, et lesquels exactement. Jamais implicite : sans Declaration, la zone est celle où se trouve le destinataire
delivery modeshared packet — la même chose pour tous, bon marché côté CPU ; ou per-actor packet — chacun le sien selon sa zone, coûteux côté CPU et nécessaire à grande population

Ce qui vaut pour toute zone.

ToujoursCe que c'est
visibilityn'est pas une permission : ce que la zone cache peut être disponible par permission, et l'inverse. La première est une optimisation de Channel, la seconde est de la sécurité — et les confondre veut dire qu'un réglage de brouillard de guerre élargit silencieusement des permissions, ou que les ACL servent à économiser de la bande passante et que les droits se mettent à dépendre de la distance
the recipientpeut abaisser le plafond : dans le maximum déclaré et jamais en dessous du minimum déclaré, parce qu'un prédicat est le même pour tous ceux dont le contexte a correspondu, tandis qu'une taille de paquet est le problème du destinataire
truncationest observable : le destinataire apprend que le paquet a été coupé et selon quel ordre. La troncature silencieuse est interdite — elle est indiscernable de l'absence d'objets supplémentaires
degradationest déclarée : quand le budget d'assemblage des paquets par Actor s'épuise, la plateforme retombe sur le paquet partagé comme déclaré, plutôt que de se mettre à perdre des destinataires arbitrairement : pire, mais d'une façon connue, au lieu d'une fuite indiscernable d'un bug de jeu
packet shapeest une promesse faible : la taille et la composition d'un paquet ne devraient pas permettre d'inférer l'existence d'objets cachés, et c'est délibérément plus faible qu'une obligation — cacher entièrement les métadonnées de flux à des volumes réels est inatteignable. Là où la fuite d'existence compte, employez des permissions, pas la zone

Erreurs

  • Une cible d'abonnement non déclarée est un refus de validation.
  • Pas de permission de s'abonner répond forbidden ou not found selon que l'existence de la cible est un secret — le refus lui-même ne doit pas laisser fuiter ce qu'il refuse.
  • Une position de reprise qui ne s'analyse pas est une requête invalide, pas un redémarrage silencieux à maintenant.
  • Un abonnement que la plateforme a fermé, et un compte d'abonnements épuisé, sont tous deux des conflits.
  • Élargir une vue est fn. Accorder une vue dérogatoire et régler les paliers de détail sont refusés forbidden à une session de joueur, et sa vue est inchangée : un client spectateur ne peut pas élargir sa propre habilitation.
  • Une instance que la vue de l'appelant exclut répond not found, la même réponse qu'une instance qui n'existe pas — un forbidden confirmerait que quelque chose se tient derrière le mur.
  • Lire le coût de paquet par Actor est fn adm — une fonction cloud ou le panneau, jamais un client demandant combien il coûte d'être regardé.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Le coût d'un paquet par Actor — à l'épuisement, dégradation déclarée vers le paquet partagé avec un avis, jamais une perte arbitraire de destinataires.
  • Objets par règle — plafonnés avec un ordre déclaré et un indicateur de troncature observable.
  • Abonnements par Actor — un nouveau est refusé et les existants continuent.
  • Taille d'un Delta — le Delta est découpé plutôt que tronqué, et le découpage est observable.
  • La cadence d'envoi — une borne supérieure, pas une garantie.

Parcours utilisateur

Une règle de rayon transforme une Room de 200 joueurs en flux de voisinage par client.

« Qui voit ceci ? » et « que voient-ils ? » sont toutes deux interrogeables, parce que la session de débogage où l'on ne peut pas y répondre est celle qui coûte cher. Le coût de paquet par Actor est une lecture de première classe, en code et dans le panneau.

The live game

Ce qui survit à la perte d'un hôte

Un hôte meurt en pleine partie. La partie, non. Cette page porte sur le second sens du mot « replication » — quelle machine possède une Entity, et quelle machine la possédera ensuite. Le premier sens, quel client reçoit quel état, est Visibility avec Data et Prediction. Qui voit quoi, et quelle machine l'exécute est là où les deux sont distingués.

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

L'état d'une Room n'est pas copié entre hôtes

Une Entity a exactement un propriétaire à la fois, et aucune seconde machine ne garde une copie vivante prête à prendre le relais.

Deux copies acceptant le même tir devraient s'accorder sur l'ordre dans lequel les deux tirs ont abouti. S'accorder sur un ordre trente fois par seconde, entre machines, c'est du consensus — et le consensus met de la latence exactement là où un jeu ne la tolérera pas. Un propriétaire unique n'a pas ce problème, et chaque mécanisme ci-dessous existe pour rendre un propriétaire unique survivable plutôt que pour le contourner.

Ce qui est répliqué, c'est la présence : quel Actor est sur quel nœud. C'est un fait petit et qui change lentement, si bien que le routage peut le connaître partout sans payer d'accord sur quoi que ce soit qui bouge.

L'état déclaré est conservé hors du host

Un état déclaré n'est pas privé au processus qui le tient. Il est capturé en snapshot à un intervalle déclaré, si bien qu'un remplaçant peut reprendre au dernier snapshot quand l'hôte précédent cesse de répondre, et le joueur revient par la fenêtre de tolérance ordinaire de Rooms.

Trois choses en découlent, et voici leur forme honnête :

  • Le remplaçant a l'état entier, mais tel qu'il était au snapshot. Complet, pas actuel. Ce que coûte une bascule, c'est le jeu écoulé entre le dernier snapshot et la perte, et l'intervalle est ce qui fixe ce pire cas.
  • La continuité du Tick n'est pas portée à travers un changement d'autorité. Un déplacement que la plateforme effectue préserve l'état de Tick du participant ; un remplacement de l'autorité ne le promet pas. Rooms est là où les deux sont déclarés, ainsi que ce qui se passe quand la fenêtre de tolérance s'écoule.
  • Tout ce que vous n'aviez gardé que dans des acteurs du moteur part avec le processus. Ce n'était jamais déclaré, rien en dehors de cet hôte ne l'a donc jamais eu.

Un déploiement est le même chemin, sans la perte

Vider un hôte — cesser d'y placer de nouvelles Rooms, laisser les sessions en cours s'achever ou passer la main, puis le lâcher — est le chemin de bascule exécuté exprès et avec préavis. C'est pourquoi déployer sans tuer les sessions en cours n'est pas un second mécanisme à construire et à éprouver : c'est celui-ci, démarré délibérément au lieu de l'être par un crash.

L'hôte de la Room l'apprend comme il apprend tout le reste : la plateforme prévient à l'avance qu'une Room va être fermée ou transmise pour une raison qui lui est propre.

Ce qui se passe une fois la fenêtre écoulée est déclaré, et il n'y a pas de valeur par défaut. Un type de Room dont l'autorité vit hors de la plateforme nomme l'une de trois issues à sa perte — attendre l'écoulement d'une fenêtre déclarée, fermer la Room, ou admettre une autorité de remplacement. Ne rien dire n'est pas une option que la Declaration offre, parce que l'alternative est la panne qu'elle existe pour empêcher : une Room à l'autorité morte qui accepte encore des entrées et retient des sièges, en montrant à chaque participant une session vivante où rien ne se passe.

Quelle machine ne fait pas partie de votre surface

Vous ne nommez jamais un nœud. Qui crée une Room ne choisit pas où elle s'exécute, et aucune opération ne prend un hôte en argument — le placement appartient à la plateforme, et il lui reste acquis pour qu'elle puisse déplacer une Room sans que votre code ait été écrit contre l'endroit où elle se trouvait.

Si vous hébergez vous-même des Rooms — un serveur dédié ou un master-client — la même chose vaut, avec un ajout : on vous dit de vous arrêter, et achever ou transmettre vos sessions à l'intérieur de la fenêtre de tolérance vous revient. Rooms est là où un hôte s'enregistre pour ce binding, et Autorité explique pourquoi l'hôte ne tient que les droits qui lui ont été accordés.

The live game

Matchmaking

Amener un joueur dans la bonne Room. Les tickets décrivent le joueur et filtrent les autres. Le matchmaker résout un placement, réserve un siège, et le trafic de jeu circule ensuite directement vers la Room.

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

Le matchmaker est sur le chemin une fois, pour décider où vous appartenez. Il n'est pas sur le chemin de la partie : son issue est un placement et une réservation de siège limitée dans le temps, et à partir de l'entrée le trafic de jeu va droit à la Room. Une file chargée ne devient donc jamais une partie chargée.

Quand l'utiliser

  • Vous avez besoin que des joueurs soient routés vers des Rooms selon des critères déclarés — mode, région, rang — et non par une liste de lobbies faite à la main.
  • Les critères de partie doivent venir des données de la plateforme, pas de la affirmation du client : estampillez le rang dans le Hook de pré-mise-en-file.
  • Les files doivent s'élargir avec le temps côté serveur pendant que le client tient un seul ticket et ne fait jamais de polling.
  • Les groupes doivent atterrir ensemble dans une même partie — un Group entre en entier ou pas du tout.
  • Vous exploitez un matchmaker externe et n'avez besoin que de voir sa décision aboutir en placement + réservation de siège.
  • Passez votre chemin quand les joueurs choisissent eux-mêmes une session — le navigateur de Rooms et Join le couvrent déjà.

Qui fait quoi

ActorSur cette page
playercrée et annule son propre ticket, et entre au sein d'un groupe
match-organizerdéclare les files du matchmaker et leur relâchement ; lit les résultats de placement
backend-serviceestampille les critères de confiance avant la mise en file ; exécute les décisions d'un matchmaker externe

En un coup d'œil

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

Les critères que le client ne doit pas se voir confier sont estampillés dans le Hook de pré-mise-en-file :

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

La lecture est la lecture par liste de propriétaires de Leaderboards — la même qu'emploie une cohorte d'amis — et chaque ligne qu'elle renvoie porte le rang de ce propriétaire dans le tableau complet. Le Hook peut demander la ligne de quelqu'un d'autre parce que le prédicat du tableau le permet à une fonction cloud ; une session de joueur posant la même question n'obtient que la sienne.

Le modèle

Ce que porte un ticket, et les deux parties ne se ramènent pas l'une à l'autre.

PartieCe que c'estQui y croit
self-descriptionles propriétés déclarées du participant — classement, mode, langue, carte choisiepersonne sans vérification : c'est la affirmation de l'appelant
requirementun prédicat que les autres doivent satisfairela plateforme, parce que c'est elle qui l'applique

Le participant d'un ticket est un Actor ou un Group — un Group entre en entier, et c'est justement ce qu'est un groupe de joueurs. Son ticket est indivisible : le Group entre en entier dans un roster, ou pas du tout, parce que scinder un Group serait une autre promesse et qu'il n'y en a aucune.

Ce qu'un type de file déclare.

DéclareCe que c'est
propertiespar nom et par type. Une propriété non déclarée ici est refusée dans un ticket comme échec de validation plutôt qu'ignorée
roster sizeun minimum, un maximum, et un pas de compatibilité — le multiple auquel une liste est acceptable, si bien que des équipes « de cinq » veut dire cinq, pas n'importe quel nombre entre deux et dix
requirement ladderun ensemble ordonné de prédicats avec des fenêtres : chaque échelon est une exigence plus large et un temps après lequel le matchmaking passe au suivant. Le relâchement est une Declaration, jamais une logique arbitraire dans un gestionnaire
the predicate languagele même que tout le reste emploie, et son vocabulaire inclut les propriétés propres au ticket — « un classement à ±100 du mien » est exprimable. Sans cela le modèle à deux faces ne fonctionne pas du tout, car les conditions relatives en sont tout l'intérêt
mutualitysi une liste est acceptable dans laquelle A accepte B tandis que B n'accepte pas A. Il n'y a pas de valeur par défaut
ticket lifetimeaprès quoi le ticket passe à expired avec un Event
outcomeRoomPlacement — une référence de Room plus des réservations dedans, pour un jeu simultané ; ou RosterSet — la liste seule, avec aucune Room et aucune réservation, pour un jeu asynchrone où l'adversaire est hors ligne

Les états d'un ticket. created → queued → matched · cancelled · expired, les trois derniers terminaux.

ToujoursCe que c'est
one live ticket per participant per queueun second est un conflit, pas une seconde candidature — lisez celui qui existe
the reason for a pairingest observable : elle atteint l'Event de matchmaking et l'historique. Pour les algorithmes livrés, c'est l'échelon de l'échelle ; une implémentation dérogatoire peut n'avoir aucun échelon, et alors la raison est une valeur opaque qu'elle déclare — mais il y en a toujours une
expiryest une issue, pas une erreur : « une liste ne s'est pas formée dans le temps déclaré » est un achèvement normal, livré comme issue du ticket
the outcomearrive par abonnement : pas par polling. Le matchmaking prend des secondes et des dizaines de secondes, si bien que le polling transformerait l'attente en une charge croissant avec la longueur de la file — le client tient un ticket et ne redemande jamais
losing the connection cancels the ticketdéclaré plutôt qu'inféré : un ticket est une candidature à jouer maintenant, et apparier un joueur absent rend la liste pire pour tous les autres
matchedest atomique : pour RoomPlacement, ou bien la liste est appariée et chaque participant tient une réservation, ou bien les tickets restent dans la file. Pour RosterSet, le résultat atomique est la liste seule

Erreurs

  • Un second ticket dans la même file est un conflit ; ne le répétez pas, lisez le ticket existant.
  • Une propriété non déclarée, ou une exigence qui en nomme une, est un échec de validation — pas un silence qui affleurerait plus tard en « aucun adversaire n'a été trouvé ».
  • La file est suspendue répond unavailable, pas forbidden : les droits de l'appelant sont intacts et la situation est temporaire, réessayer avec un backoff est donc juste.
  • La Room du résultat ne peut pas être créée est de même unavailable, avec un backoff.
  • La réservation a échoué est un conflit qui vaut la peine d'être retenté — le ticket reste dans la file.
  • Un ticket introuvable ou appartenant à un autre, et un Actor qu'un prédicat n'admet pas dans la file, répondent tous deux not found, si bien qu'un refus ne révèle ni le ticket ni la file.
  • « Une liste ne s'est pas formée » n'est jamais une erreur — voir l'expiration ci-dessus.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Tickets dans une file — la création est refusée comme conflit, et les tickets existants ne sont pas évincés pour faire de la place.
  • La durée de vie d'un ticket — un passage à expired avec un Event.
  • Les échelons de l'échelle — une Declaration qui en a trop est refusée au moment de la Declaration.
  • La taille d'un Group dans un ticket — le ticket est refusé comme échec de validation.
  • Les propriétés déclarées par type de file — refusées au moment de la Declaration.
  • La cadence de création de tickets — un refus de limite de débit avec une échéance.
  • La rétention de l'historique de matchmaking — au-delà de la période, une entrée est illisible par la période déclarée.

Parcours utilisateur

De la connexion jusqu'à se tenir dans la Room de la partie, avec le rang estampillé côté serveur. Le voyage commence à Auth parce qu'un ticket a un propriétaire : sans session, il n'y a personne à mettre en file.

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Map

Le monde statique : bornes, terrain, obstacles, et « où les choses peuvent-elles aller ? ». Le modèle physique est délibérément bien plus simple que le modèle visuel : des primitives avec une empreinte et une hauteur, des couches avec des règles, et une seule requête de position valide que tous les autres modules réutilisent.

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Vous avez besoin d'un monde statique — bornes, terrain, obstacles — que le serveur puisse interroger, et pas seulement rendre.
  • Les apparitions, les drops et les décorations doivent atterrir à des endroits légaux : une seule requête RandomPosition fondée sur des règles, sans contournement.
  • Les arènes doivent se régénérer à chaque partie — un Seed déclaré reproduit la même carte dans un rapport de bug.
  • Les caisses et les murs se cassent et reviennent — des destructibles avec des HP et des minuteurs de réapparition.
  • Les bots et les projectiles ont besoin de réponses de lancer de rayon et de ligne de vue contre l'obstacle set.
  • Passez votre chemin quand le monde est purement visuel et qu'aucun code serveur ne demande où les choses peuvent aller.

Qui fait quoi

ActorSur cette page
schema-authordéclare les cartes, les primitives d'obstacle, les destructibles, les couches et leurs règles
room-ownerlie une carte à une Room ; demande des positions d'apparition ; lance des rayons
operatorplace ou retire des obstacles et des couches depuis le panneau

En un coup d'œil

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter et Destructible sont des générateurs de placement, pas des tirages à l'exécution. Un générateur se résout à la publication de la version de la carte : les quarante rochers deviennent quarante primitives déclarées, et la version publiée porte les primitives, pas la règle. Le même Seed donne donc les mêmes quarante rochers dans la partie, dans le replay et dans le rapport de bug — et les limites de géométrie sont vérifiées une fois, sur cet ensemble résolu, avant que la version n'atteigne un Environment.

La requête que tout le reste pose :

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

Le modèle

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

Deux couches, déclarées par des gens différents.

CoucheCe qu'elle tient, et qui la déclare
staticterrain avec hauteur, primitives d'obstacle, bornes et emplacements — du contenu écrit
dynamicdes obstacles apportés par des Entities à l'exécution : portes, destructibles, plateformes. Un destructible est donc une Entity avec un cycle de vie, des états et un propriétaire, et il ne devient carte que dans la part où il apporte un obstacle — un rocher statique est déclaré dans la carte, une porte est une Entity qui en apporte un. Ceux-ci n'ont aucun cycle de vie propre ici : il appartient à l'Entity

Ce qu'une carte déclare.

DéclareCe que c'est
key et versionune carte est du contenu écrit : déclarée en code, adressée par key, et versionnée — la version fait partie de ce qu'une Room référence. Changer la géométrie d'une version publiée est interdit ; une retouche est une nouvelle version
terrainun champ de hauteurs — une grille régulière avec un pas déclaré, et le pas est une limite de précision déclarée, si bien qu'une requête de hauteur répond à cette précision plutôt qu'exactement. Le terrain peut être absent : une arène dans le vide est licite
obstaclesun ensemble fermé de primitives — boîte, sphère, capsule, enveloppe convexe avec une limite de sommets déclarée. Un maillage triangulaire arbitraire n'est pas accepté, ce qui est la condition pour qu'une vérification côté serveur soit possible tout court
passability kind par obstacleinfranchissable · franchissable pour une classe déclarée · bloque seulement la ligne de vue. Une même primitive sert de mur et de buisson, et la différence est déclarée plutôt que modélisée deux fois
boundsle volume hors duquel une position est inadmissible
world stratades strates spatiales déclarées à l'intérieur d'une carte — sol, souterrain, air. Ce sont des déclarations de géométrie et d'adressage
locationsdes lieux ou zones nommés — un point d'apparition, une zone de capture, un couloir. Un emplacement répond où, jamais ce qui se passe : il ne porte aucune logique de jeu
placement generatoroptionnellement, une règle produisant des primitives — un nombre, un espacement minimal, une zone, une graine. Elle est résolue à la publication d'une version, de façon déterministe selon la graine, et ensuite la carte tient des primitives plutôt qu'une règle

Une strate de monde et une instance de carte ne sont jamais des synonymes.

Ce que c'est
world stratumune déclaration à l'intérieur de la carte — sol, souterrain, air
map instanceune copie d'exécution indépendante de la carte publiée. Les instances partagent la géométrie publiée immuable et ont des obstacles dynamiques indépendants et des listes d'Entities indépendantes. Une Room occupe une instance et peut y sélectionner des strates

Ce qui vaut pour toute requête.

ToujoursCe que c'est
one geometric canontoute la géométrie est dans le canon de coordonnées déclaré de la plateforme, et la précision de chaque champ géométrique est déclarée sur le champ
the world modelest une simplification : la géométrie du serveur n'est pas le modèle artistique, et elle n'est pas tenue de l'être
an answer names its instance and its momentune requête reçoit sa réponse depuis la couche statique de la carte plus les obstacles dynamiques de l'instance sur laquelle elle a porté, et elle déclare l'instant pour lequel elle est vraie — les obstacles dynamiques changent, la réponse est donc un instantané
the valuessont gérées, pas semées : la géométrie n'est pas le réglage quotidien d'un game designer : une retouche depuis la console d'administration est refusée plutôt que conservée en silence
movement and contactne sont pas résolus ici : elle répond à ce qu'est l'espace ; savoir si une position est admissible et quelle est la réponse relève de Collision, et l'appliquer relève de Locomotion

Erreurs

  • Une carte, une version ou une instance introuvable répond not found, et une version retirée de même — répéter est inutile.
  • Publier une géométrie modifiée sous une version existante est un conflit : faites une nouvelle version.
  • Les échecs de publication tombent à la Declaration, au déploiement, jamais à l'exécution — une carte au-delà de la limite de primitives, une enveloppe convexe au-delà de sa limite de sommets, et un maillage arbitraire en guise d'obstacle sont tous des refus de validation avant toute livraison.
  • Une requête de hauteur hors des bornes n'est pas une erreur — c'est la réponse déclarée « hors des bornes », et elle est distinguable de « à l'intérieur d'un obstacle », parce qu'un client fait demi-tour dans un cas et contourne dans l'autre.
  • La cadence de requêtes dépassée répond dans la catégorie limite de débit, avec une échéance.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Primitives d'obstacle par carte, sommets d'enveloppe convexe, résolution du champ de hauteurs, taille des bornes, emplacements par carte — chacun d'eux est refusé à la publication, pas au moment de la requête : une carte qui est livrée est une carte qui rentre déjà.
  • Instances de carte par carte — en créer une de plus est refusé comme conflit ; les instances existantes ne sont jamais libérées pour faire de la place.
  • Versions conservées — la plus ancienne version en dépréciation est retirée, et une version sous une Room vivante ne l'est jamais.
  • La cadence de requêtes à l'espace — une limite de débit avec une échéance.

Parcours utilisateur

Un largage planifié demande à la carte un endroit légal, et un joueur va le récupérer. La table de drop est un entity preset — une Declaration sur une Entity, pas un module que vous montez.

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Collision

Liez une transformation à la carte des obstacles ; déclarez ce que fait le contact. La collision s'exécute à l'intérieur de la simulation de la plateforme. Vous déclarez des corps, des couches et des réponses, et vous vous abonnez aux contacts.

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

Quand l'utiliser

  • Des Entities en mouvement doivent résoudre des contacts côté serveur — glisser, s'arrêter, rebondir — sans routine de déviation écrite à la main.
  • Le gameplay réagit au toucher : les ramassages se collectent au chevauchement, les volumes déclencheurs actionnent une machine à états d'Entity.
  • Locomotion et les projectiles ont besoin d'une résolution balayée contre l'obstacle set de la Map.
  • Les aperçus de placement ou le ciblage ont besoin de « est-ce que cela rentrerait ici ? » et de requêtes de chevauchement de volumes.
  • Passez votre chemin quand rien ne se rencontre physiquement — un gameplay requête/réponse sur des enregistrements est simplement Data.

Qui fait quoi

ActorSur cette page
room-ownerdéclare les corps, les couches et les réponses ; interroge les chevauchements et les contacts

À quelles Rooms cela s'applique. Ce module s'exécute là où la plateforme fait avancer la simulation — les Rooms déclarées avec Host = "Backend". Si votre propre game server est propriétaire de la simulation (PlayServ en métaserveur), le mouvement, la collision et la prédiction restent côté moteur, et cette page décrit l'alternative hébergée par la plateforme, pas une obligation.

En un coup d'œil

La forme, la couche et ce que fait le contact se posent tous sur le corps lui-même — rien ne déclare de paires de couches à distance :

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

Un contact est un Event, et les modules s'y abonnent. Il n'y a pas de Hook sur un contact : au moment où il existe, le pas l'a déjà résolu, il ne reste donc rien à rejeter. Là où un studio a besoin de règles différentes, il redéfinit les vérifications d'admissibilité et de trajet comme une implémentation — voir Extensibility — et la réponse reste déclarée.

Le modèle

Ce qu'un corps déclare.

DéclareCe que c'est
shapeune primitive issue d'un ensemble fermé — sphère, capsule, boîte — avec des dimensions déclarées. Un maillage arbitraire n'est pas proposé, la même contrainte et la même raison que pour le modèle de monde du serveur dans Map
where it livessur un aspect, avec la transformation : c'est l'unité de politique, et un corps partage le sort de sa transformation
how its path is checkedstepwise — la position finale du pas est vérifiée, c'est rapide, et un corps rapide traverse un obstacle mince ; ou swept — le segment entre les positions est vérifié, plus coûteux, et le tunnelage est exclu à l'intérieur d'un pas. Déclaré, jamais choisi par une implémentation d'après la vitesse : qu'un projectile puisse traverser un mur est une propriété du jeu, pas une optimisation
areas it participates inquels volumes le comptent comme étant à l'intérieur
its relation to the art modelaucune n'est exigée : un corps est une simplification, et l'écart avec le modèle artistique est admissible dans des bornes déclarées

La réponse est déclarée sur une paire — le genre de franchissabilité d'un obstacle × un type de corps — et elle vient d'un ensemble fermé :

RéponseCe que cela veut dire
stople mouvement cesse à la dernière position admissible
slidele mouvement continue le long de l'obstacle selon la composante admissible
bouncela direction est réfléchie et la vitesse multipliée par un coefficient déclaré
dample mouvement continue avec la vitesse multipliée par une fraction déclarée
passl'obstacle n'affecte pas le mouvement, mais le contact reste observable
cease to existl'Entity prend fin — un projectile contre un mur

Les coefficients sont des valeurs déclarées plutôt que calculées à partir de masses et de matériaux — il n'y a ni les unes ni les autres dans ce contrat.

Ce qui vaut pour toute vérification.

ToujoursCe que c'est
every paira une réponse : une paire manquante est un défaut de Declaration, refusé au déploiement plutôt que rencontré en plein combat
the response tableest lisible par le client : la même table que celle sur laquelle l'autorité calcule, si bien qu'un client et un serveur, une même Declaration en main, répondent identiquement à un même contact
reproducible within one authority, not across platformsla même entrée dans le même ordre donne le même résultat au sein d'un même processus et d'un même build. Des résultats identiques au bit près sur des plateformes et des builds différents ne sont pas promis, et un modèle réseau bâti sur l'hypothèse que les collisions se calculent identiquement partout est bâti sur du sable
simultaneityest déclarée : quand deux corps en mouvement entrent en collision au sein d'un même pas, l'ordre de résolution est déclaré et déterministe. L'ordre de parcours du stockage, l'ordre d'arrivée des entrées et le hasard n'ont pas le droit d'en être le fondement
one contact, one factun contact entre deux corps est observable par les deux côtés comme un fait unique doté d'un identifiant unique, non comme deux Events indépendants
extension points sit on the step, not on a contactavant le pas, la transformation peut être changée ; après lui, il y a observation. Un contact a déjà eu lieu, il n'y a donc rien à rejeter ; des règles différentes sont une redéfinition déclarée des vérifications d'admissibilité et de trajet, et une telle redéfinition est obligée d'être disponible au client aussi
the modulene déplace rien lui-même : il répond si une position est admissible et quelle est la réponse ; l'appliquer revient à Locomotion
a contact is an eventet c'est pourquoi les modules s'abonnent au lieu de se coupler : la machine à états d'un piège lie une transition à l'entrée dans un volume déclencheur, drops collecte au chevauchement, et les projectiles résolvent les impacts par le balayage de ce module

Erreurs

  • « Inadmissible » est une réponse, pas une erreur, et elle nomme laquelle des trois raisons : hors des bornes, occupé par un obstacle statique, ou occupé par le corps d'une autre Entity. Un client réagit différemment aux trois — faire demi-tour, contourner, ou attendre — si bien que les ramener à « non » coûterait du comportement.
  • Les échecs de Declaration tombent au déploiement, jamais au premier contact : un corps de forme hors de l'ensemble fermé, un corps sur un aspect sans transformation, et une paire sans réponse déclarée sont tous refusés au déploiement. Une collision se produit en plein combat, et un échec à l'exécution y est observé comme un mur qui a disparu.
  • L'Entity ou l'emplacement est introuvable répond not found, et répéter est inutile.
  • La cadence de vérifications dépassée répond dans la catégorie limite de débit, avec l'échéance avant laquelle un réessai est inutile.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Corps dans une Room — en déclarer un de plus est refusé comme conflit ; les corps existants ne sont jamais retirés pour faire de la place.
  • Contacts par pas — l'excédent n'est jamais écarté en silence : ou bien le pas est refusé, ou bien l'ordre de coupure est déclaré.
  • La taille d'un corps face au pas de grille de la carte — refusée au déploiement, parce qu'un corps plus petit que le pas du champ de hauteurs traverse le terrain, et que cela ne peut pas être une surprise à l'exécution.
  • Zones dans lesquelles un même corps peut se trouver — un excédent est refusé au déploiement.
  • La cadence de vérifications par Actor — une limite de débit avec une échéance.
  • Corps dans une réponse « qui est dans cette zone » — tronqués selon un ordre déclaré, et l'indicateur de troncature est obligatoire.

Parcours utilisateur

Un volume déclencheur, une machine à états et une porte : les Events de contact font tout le câblage. La plaque et la porte sont des objets de monde — des entity presets, pas des modules que vous montez.

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Locomotion

Vous déclarez comment une chose se déplace ; personne n'écrit d'intégrateur. Un modèle de déplacement transforme une entrée séquencée en mouvement faisant autorité, intégré avec Collision, enregistré pour Prediction, et modifié par les bonus, les malus et le terrain.

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Des Entities se déplacent sous l'entrée du joueur — tanks, personnages, véhicules — et le mouvement doit faire autorité côté serveur.
  • Vous préférez déclarer des limites de vitesse, d'accélération et de taux de rotation qu'écrire un intégrateur.
  • Le gameplay bouscule les corps : knockbacks par Impulse, Teleport, et modificateurs de type boue avec des durées.
  • Le mouvement doit sembler instantané : le même modèle déclaré avance sur le serveur et dans la boucle de Prediction.
  • Passez votre chemin quand les positions ne changent que par pas discrets — un champ synchronisé sur l'Entity le couvre déjà.

Qui fait quoi

ActorSur cette page
schema-authordéclare les modèles de déplacement, les contraintes et les liaisons
room-ownerapplique impulsion, téléportation et modificateurs depuis l'hôte
playersoumet une entrée séquencée ; lit l'état de mouvement

À quelles Rooms cela s'applique. Ce module s'exécute là où la plateforme fait avancer la simulation — les Rooms déclarées avec Host = "Backend". Si votre propre game server est propriétaire de la simulation (PlayServ en métaserveur), le mouvement, la collision et la prédiction restent côté moteur, et cette page décrit l'alternative hébergée par la plateforme, pas une obligation.

En un coup d'œil

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

L'entrée du client est une intention séquencée. La plateforme fait avancer le mouvement :

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

Verbes côté serveur :

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

Le modèle

Ce qu'une Entity déclare pour se déplacer.

DéclareCe que c'est
movement modell'un de l'ensemble livré — steering, tank, character, vehicle, flying — comme plusieurs implémentations d'un même pas, avec des conditions de choix déclarées et une valeur par défaut. Le pas est une fonction pure (pose, input, dt) → pose
parametersdes valeurs déclarées que le client peut lire ; sans elles, la prédiction diverge systématiquement. Ce sont des seed — un game designer les ajuste et un déploiement ne doit pas perdre ses retouches en silence — tandis que les limites sur lesquelles repose l'anti-triche peuvent être managed, et alors une retouche depuis la console d'administration est refusée
limitsvitesse maximale, accélération et freinage, virage maximal par entrée, multiplicateur de marche arrière, et une vitesse de rotation distincte pour les parties. Le virage par entrée est déclaré à part de la vitesse de rotation exprès : l'un borne un saut instantané, l'autre un taux continu, et ce sont des défenses différentes
behaviour on stale inputstop, continue until a declared deadline, ou continue indefinitely. Il n'y a pas de valeur par défaut « continuer comme avant » — un joueur dont le réseau a lâché continuerait de rouler
pose toleranceà quelle distance de celle du serveur une pose prétendue a le droit de se tenir, et cela peut différer selon l'état : à l'arrêt, en mouvement, et juste après une réapparition sont trois tolérances différentes
step rate and catch-up capà quelle fréquence le pas s'exécute, et combien de pas peuvent être pris d'un coup quand le serveur est en retard
impulse kindschacune avec son amplitude et sa manière de décroître

Ce qui vaut pour tout pas.

ToujoursCe que c'est
the module ownsla position et l'orientation d'une Entity au cours du temps, et rien d'autre. L'historique de ces positions est tenu par Entity plutôt qu'ici, si bien que « où était le joueur il y a 300 ms » a exactement une réponse au lieu de deux tampons de périodes différentes
authorityest celle du serveur : sous le mode our simulation, un client envoie une intention, jamais un résultat
collisionsne sont pas résolues ici : il demande à Collision si une position est admissible et quelle est la réponse, et ne tient aucune table de réponses à lui
inputest une intention : « en avant », « à droite », « tourner la tourelle par là » — acceptée telle qu'elle vient, parce qu'elle n'affirme rien sur le monde
a claimed poseest une affirmation : jamais un fait. Hors de la tolérance déclarée elle est ramenée à la pose admissible la plus proche, et cela produit un pose_clamped observable
input sequencingest requis : le même numéro de séquence n'est jamais appliqué deux fois, et un numéro plus bas est écarté
a limitborne, il ne refuse pas : « dix mètres en avant sur ce Tick » devient ce qui est admissible plutôt qu'une erreur. C'est ce qui fait d'une limite un anti-triche par construction — le serveur ne peut physiquement pas produire la pose illégale — et c'est pourquoi le client n'est pas inondé de refus à chaque frame
an impulse obeys the same constraintsle recul, une poussée, une explosion et un knockback arrivent hors de l'entrée, et aucun d'eux ne contourne Collision : le recul n'enfonce pas un tank dans un rocher
identical rules, not identical bitsaucun résultat identique au bit près entre plateformes n'est promis. Ce qui est promis, ce sont les mêmes règles, et la reproductibilité au sein d'une même autorité
the stepest pur et piloté par le Tick : le même code fait avancer le mouvement sur le serveur et à l'intérieur de la boucle de prédiction du client, et c'est ce qui rend la réconciliation exacte

Erreurs

  • Un modèle de déplacement manquant sur l'Entity, un preset partiellement rempli, et une impulsion sans décroissance déclarée sont tous des échecs de validation au déploiement, pas à l'exécution — une impulsion sans fin est un défaut de Declaration, elle n'atteint donc jamais un joueur.
  • La génération attendue ne correspondait pas est un échec de précondition, à répéter après relecture : une entrée envoyée avant une réapparition ne doit pas être appliquée après elle.
  • La cadence d'entrée dépassée répond dans la catégorie limite de débit, avec une échéance.
  • L'Entity n'est pas contrôlable est un conflit, et le répéter n'a de sens qu'après un changement d'état.
  • Trois choses ne sont des refus dans aucun sens, et les trois sont observables. Une entrée périmée est écartée, une intention au-delà d'une limite est bornée, et une pose au-delà de la tolérance est bornée en pose_clamped. Faire l'une de ces trois choses en silence laisserait le client croire qu'il l'a appliquée et diverger du serveur pour de bon.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Vitesse et accélération maximales — bornées, jamais refusées.
  • Virage maximal par entrée — borné.
  • Cadence d'entrée par Actor — une limite de débit avec une échéance.
  • Pas de rattrapage — au-delà du plafond, les pas sont écartés avec une conséquence déclarée : le temps de simulation prend du retard et cela est observable, plutôt que rattrapé d'un bond qui se lit comme si tout le monde se téléportait d'un coup.
  • Amplitude d'impulsion — bornée au maximum déclaré.
  • Impulsions simultanées par Entity — une nouvelle évince la plus ancienne, et l'éviction est observable ; il n'y a pas de sommation silencieuse et sans borne.
  • La durée de vie d'une pose prétendue — une pose plus ancienne que la période déclarée n'est pas considérée.

Parcours utilisateur

Le voyage d'un knockback : le player conduit, l'attacker dans l'autre tank tire, et l'impulsion atterrit comme une pose réconciliée sur l'écran de la victime. L'ability et le projectile sont des entity presets — des Declarations sur des Entities, pas des modules que vous montez.

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Prediction & Lag Comp

Le joueur a appuyé sur saut il y a 50 ms. Le paquet n'est arrivé que maintenant. Il n'est pas tombé. Prédiction en avant et compensation en arrière sur des données qui portent leur véritable heure d'événement : le client se sent instantané, le serveur reste juste, et les impacts sont jugés dans la chronologie du tireur.

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • L'entrée doit sembler instantanée sous la latence pendant que le serveur garde l'autorité — prédire en avant, réconcilier à la divergence.
  • Les impacts doivent être jugés dans la chronologie du tireur : ResolveAt rembobine les boîtes de collision jusqu'au Tick de vue signalé.
  • Les arcs de visée et les marqueurs d'atterrissage doivent correspondre aux résultats — client et serveur prévoient la même Trajectory.
  • Les champs critiques pour le jeu ne doivent jamais revenir en arrière — déclarez ce qui se prédit et ce qui attend le serveur.
  • L'effet élastique demande du réglage : fenêtres par Entity, tolérances, et télémétrie des erreurs de prédiction.
  • Passez votre chemin quand la latence ne fait pas mal — les jeux au tour par tour ou lents tournent très bien sur de simples Deltas Data.

Qui fait quoi

ActorSur cette page
schema-authordéclare les champs prédits et ceux réservés à l'autorité ; fixe la fenêtre de prédiction
room-ownerrésout les impacts sur un état historique ; rembobine le monde
playerprédit et réconcilie le mouvement ; s'abonne aux corrections

À quelles Rooms cela s'applique. Ce module s'exécute là où la plateforme fait avancer la simulation — les Rooms déclarées avec Host = "Backend". Si votre propre game server est propriétaire de la simulation (PlayServ en métaserveur), le mouvement, la collision et la prédiction restent côté moteur, et cette page décrit l'alternative hébergée par la plateforme, pas une obligation.

En un coup d'œil

Le mot recouvre trois choses différentes, elles ne doivent pas être fusionnées, et chacune a son propre article. Elles ont des autorités différentes et des modes de défaillance différents — un seul mot pour les trois signifie qu'en régler une change silencieusement les deux autres.

MécanismeCe qu'il faitS'exécute surQuand il se trompe
Prédire votre propre mouvementapplique le modèle déclaré à votre propre entrée sans attendre le serveurle clientune correction, rejouée et lissée
Afficher les autres joueursdessine les autres Entities entre les états qui arriventle clientune saccade visible
Compensation de lagrembobine les cibles jusqu'au moment que le tireur voyaitle serveurquelqu'un meurt injustement

Cette page est le carrefour : le modèle commun, les Declarations communes, et les presets qui choisissent une combinaison pour vous. Les trois articles sont là où chaque mécanisme est réellement expliqué.

Quatre presets, et « pas de prédiction » en est un.

PresetPrédit le vôtreCompenseLisse les autres
shooterouidans une fenêtre d'environ une seconde et demieoui
arcadeouinonoui
observernonnonoui
pas de prédictionnonnonnon — l'état arrive de l'autorité avec une fenêtre d'interpolation déclarée

Le dernier n'est pas une ébauche. Les jeux au tour par tour, les jeux de stratégie et la plupart des titres mobiles ne veulent aucune prédiction, et un « nous ne prédisons pas » déclaré dit au client d'afficher l'état tel qu'il est plutôt que de deviner.

Rien de tout cela ne s'applique sous autorité externe. Les trois mécanismes existent pour les Rooms que notre simulation exécute. Quand le serveur de jeu d'un studio ou un master-client possède le Tick, la prédiction est l'affaire de qui l'exécute — voir Qui conduit le Tick.

Le module ne possède aucun modèle de déplacement, aucune table de réactions, aucune géométrie ni aucune fenêtre d'historique à lui. Celles-ci appartiennent respectivement à Locomotion, Collision, Map et Entity. La prédiction les applique plus tôt ou les lit à rebours ; elle n'en déclare jamais une seconde copie.

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

La résolution compensée en lag répond à « où était chacun quand ce tir est parti » :

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

La prévision de trajectoire, partagée par le serveur et le client (arcs de visée, marqueurs d'atterrissage). La prévision est une opération de ce module, extrapolée contre l'obstacle set de la Map, si bien que les deux côtés dessinent le même arc à partir des mêmes entrées :

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

Le modèle

La prédictibilité se déclare sur un aspect — et un aspect changé uniquement par l'autorité, selon des règles que le client n'a pas, n'a pas le droit d'être marqué prédictible : c'est un échec de validation au déploiement, pas une surprise à l'exécution.

Ce qui est déclaré.

DéclareCe que c'est
predictable aspectslesquels le client a le droit d'avancer devant l'autorité
divergence thresholden dessous, une correction est lissée ; au-dessus, l'état de l'autorité est accepté tel qu'il vient. Déclaré, et managed — pas un bouton quotidien de game designer
display mode for remote entitiesinterpolation entre les états arrivés, ou extrapolation
interpolation delayde combien l'affichage des autres est en retard, déclaré plutôt que réglé au feeling
extrapolation windowau-delà, une Entity est marquée périmée et l'extrapolation cesse
compensation windowjusqu'où en arrière un rembobinage a le droit d'aller, et c'est managed
what is rewoundles positions et orientations des cibles, et la géométrie des obstacles dynamiques si elle est déclarée historique

Ce qui est rembobiné, et ce qui délibérément ne l'est pas.

Ce que c'est
rewoundles positions et orientations des cibles, et la géométrie des obstacles dynamiques là où le type les déclare historiques
not rewoundl'état de vie — les morts ne ressuscitent pas pour se faire tirer dessus — ainsi que la propriété, le score et l'inventaire
the rule behind the splitla décision est prise dans le passé ; l'effet est appliqué au présent
ToujoursCe que c'est
authoritative state names the input it sawil porte le numéro de la dernière entrée appliquée, et c'est ce qui rend la réconciliation exacte plutôt qu'approximative
divergenceest observable : le client sait que sa prédiction a été corrigée, au lieu de dériver en silence
a view timeest une affirmation, pas un fait : l'instant que l'Actor dit avoir vu. Au-delà de la fenêtre, la plateforme refuse au lieu d'extrapoler : une extrapolation silencieuse est un cadeau au tricheur, à qui il suffit d'envoyer un instant plus ancien
a rewind promises no reproducibility over floating pointla même contrainte que partout ailleurs dans le contrat
one history ring, two consumersla réconciliation et les requêtes compensées en lag lisent toutes deux la piste d'historique instantané de l'Entity. Le rembobinage appartient ici plutôt qu'aux modules rembobinés : l'anneau restitue les poses du Tick en litige, et l'on pose ensuite à Collision sa question ordinaire de chevauchement sur ces poses — elle ne garde aucun historique à elle et ne connaît rien à un « Tick de vue »
client tick:  apply input locally (predict) → buffer it → send, tick-stamped
server tick:  step the same movement model → authoritative state → delta out
client recv:  authoritative state for tick T → if divergence beyond tolerance:
              rewind to T → replay buffered inputs T+1..now → smooth

Seuls les champs déclarés prédits reviennent jamais en arrière ; une dérive minime est lissée, une vraie divergence rembobine et rejoue.

Erreurs

  • Un instant de vue au-delà de la fenêtre est un conflit : envoyez-en un courant. Extrapoler à la place remettrait tout le mécanisme à un tricheur.
  • Une demande d'historique au-delà de la fenêtre est de même un conflit.
  • Deux défauts de Declaration sont attrapés au déploiement : une fenêtre de compensation plus grande que le tampon d'historique, et un aspect marqué prédictible alors que le client n'a pas de règles pour le prédire. Ni l'un ni l'autre ne peut atteindre une partie en direct.
  • Deux choses ne sont pas des erreurs, et les deux sont observables. Un tampon d'entrées plein suspend la prédiction jusqu'à confirmation au lieu d'écarter des entrées en silence ; et une divergence au-delà du seuil signifie que l'état de l'autorité est accepté tel qu'il vient, ce qui est la correction déclarée et non une faute.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • La fenêtre de compensation — au-delà, un refus, jamais une extrapolation.
  • La fenêtre d'extrapolation pour les autres — l'Entity est marquée périmée et l'extrapolation s'arrête.
  • Le tampon d'entrées non confirmées — la prédiction est suspendue jusqu'à confirmation ; les entrées ne sont jamais écartées en silence.
  • La profondeur d'historique pour un rembobinage — pas moins que la fenêtre de compensation, et c'est vérifié au déploiement.
  • La cadence des actions portant un instant de vue — une limite de débit avec une échéance.
  • Entities prédites simultanément par client — au-delà du plafond, la prédiction n'est pas effectuée, et c'est une dégradation déclarée plutôt qu'un refus.

Parcours utilisateur

Un tir sous latence, jugé équitable dans la chronologie du tireur et confirmé sur les deux écrans. Le projectile et le bloc de Stats de la victime sont des entity presets — des Declarations sur des Entities, pas des modules que vous montez.

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

Prédire votre propre mouvement

C'est la machine propre au client, et c'est le seul des trois mécanismes de prédiction dont les erreurs coûtent peu. Vous agissez sur votre propre entrée avant que le serveur ait répondu, le serveur répond, et là où les deux divergent votre client se corrige. Une erreur ici coûte une petite correction visuelle — ce qui explique justement qu'il soit sûr d'être agressif là-dessus.

La prédiction est une répétition des règles déclarées, pas une seconde copie

Votre client n'exécute pas une implémentation parallèle de votre mouvement. Il exécute le même modèle déclaré que la plateforme exécute — le modèle appartient à Locomotion, et la prédiction ne fait que l'appliquer plus tôt. C'est là toute la raison pour laquelle les deux côtés s'accordent la plupart du temps : il y a un seul jeu de règles, appliqué deux fois.

Ce qui veut dire qu'il n'y a pas d'opération « prédire » à appeler, ni d'opération « corriger » non plus. La prédiction a lieu parce que l'aspect a été déclaré prédictible, non parce que vous avez invoqué quelque chose.

Ce qui se prédit est déclaré par aspect

La prédictibilité est une Declaration sur l'aspect d'Entity, et ce n'est délibérément pas un interrupteur global :

  • Un aspect que le client peut calculer — la position sous votre propre entrée — a le droit d'être prédit.
  • Un aspect que l'autorité change selon des règles que le client n'a pas ne doit pas être prédit. Si le client ne peut pas le dériver, le deviner produit un retour en arrière que le joueur lit comme un mensonge du jeu.

Cette ligne est là où vous décidez de ce qui a le droit de vaciller et de ce qui doit être juste du premier coup.

Le protocole de correction, et les deux nombres qui lui donnent sa forme

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

L'état faisant autorité arrive en portant le numéro de la dernière entrée qu'il a appliquée, si bien que votre client sait exactement quelle part de son propre tampon est encore non confirmée. À partir de là :

  1. Acceptez l'état faisant autorité.
  2. Rejouez les entrées mises en tampon venues après celle qu'il accuse.
  3. Réconciliez le résultat avec ce que vous montriez déjà.

Deux nombres déclarés décident du ressenti. Le seuil de divergence : en dessous, la correction est lissée ; au-dessus, votre client saute et rejoue. Et la borne du tampon d'entrées non confirmées : le débordement n'est pas indéfini — la dégradation est déclarée et observable, si bien qu'un client sur une mauvaise connexion sait qu'il a cessé de prédire au lieu de dériver en silence.

La divergence est observable pour le client qui l'a eue, et pour ce client-là seulement. Vous pouvez savoir que votre prédiction a été corrigée et de combien — utile pour le réglage, et pour montrer au joueur un indicateur de connexion honnête. Vous ne pouvez pas lire la divergence de quelqu'un d'autre : l'ampleur d'une erreur de prédiction est une information sur sa connexion, pas sur le jeu. La correction est côté client, parce que l'état de l'autorité est ce qu'on montrait déjà à tous les autres.

Si vous arrivez d'ailleurs

  • Mover 2.0 d'Unreal. La forme est familière : entrées estampillées par Tick, un modèle de déplacement, des corrections venant de l'autorité. La différence est là où vit le modèle — ici vous le déclarez et la plateforme le simule, il n'y a donc aucun composant de déplacement à nous que vous puissiez sous-classer ou remplacer.
  • Le netcode rollback-and-replay, comme dans Photon Fusion. Rejouer vos propres entrées non confirmées après une correction est le même mécanisme, et il est intégralement ici. Ce qui n'est délibérément pas ici, c'est le fait de réexécuter le monde après coup — voir Compensation de lag pour ce qui se passe à la place, et pourquoi.

Ce que cela ne couvre pas

Les Entities des autres joueurs ne sont pas prédites, elles sont affichées — c'est Afficher les autres joueurs. Juger un tir dans la chronologie du tireur est un mécanisme serveur et vit dans Compensation de lag. Et aucun des trois ne s'applique quand le mode d'autorité de la Room est externe : le Tick appartient alors à qui l'exécute, et la prédiction aussi.

The live game

Afficher les autres joueurs

Personne ne prédit les autres joueurs — ils sont affichés. Vous recevez leur état par intervalles et vous devez dessiner quelque chose entre les deux. Une erreur ici ne coûte la vie à personne ; elle coûte une saccade visible, et c'est pourquoi elle reçoit ses propres Declarations plutôt que de partager celles de la prédiction.

Le mode d'affichage est déclaré, pas deviné

Pour les Entities qui ne sont pas les vôtres, la Room déclare comment combler l'intervalle entre les états qui arrivent : interpoler entre les états dont vous disposez, ou extrapoler au-delà du plus récent. C'est une Declaration sur l'Entity, si bien que la réponse est la même sur chaque client et ne dérive pas selon qui a implémenté le rendu.

Le délai d'interpolation est déclaré lui aussi. Montrer les autres joueurs de façon fluide veut dire les montrer légèrement en retard, d'un montant déclaré. Nommer le nombre est tout l'intérêt : un délai non énoncé est un rapport de bug irreproductible, et un délai énoncé est une décision de conception que vous pouvez régler selon votre genre.

L'extrapolation s'arrête au lieu d'inventer

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

La fenêtre d'extrapolation est déclarée, et au-delà l'Entity cesse d'être affichée en mouvement plutôt que de continuer sur une supposition. Extrapoler indéfiniment met le joueur en train de tirer sur une cible qui n'a jamais été là, et le joueur ne peut pas s'en rendre compte — un gel visible est la défaillance dont on revient.

Pourquoi c'est séparé de prédire votre propre mouvement

Les trois mécanismes de prédiction ont des autorités différentes et des modes de défaillance différents, et un seul mot pour les trois signifie qu'en régler un change silencieusement les deux autres.

MécanismeS'exécute surQuand il se trompe
prédire votre propre mouvementle clientune correction, rejouée et lissée
afficher les autres joueursle clientune saccade visible
compensation de lagle serveurquelqu'un meurt injustement

C'est aussi pourquoi il existe un preset observer qui porte ce mécanisme et rien d'autre : un spectateur n'a pas d'entrée à lui à prédire, lui donner des réglages de prédiction reviendrait donc à configurer quelque chose qu'il ne fait pas.

The live game

Compensation de lag

C'est le mécanisme du serveur, et le seul des trois dont les erreurs tuent quelqu'un. Quand il tranche mal, un joueur meurt injustement — et en faveur de celui qui a la moins bonne connexion. Tout ce qui est sur cette page est façonné par cette asymétrie.

La question à laquelle il répond est étroite : qu'a réellement vu le tireur ? Une action peut porter un instant de vue, le Tick que l'Actor regardait au moment d'agir, et la plateforme restitue les poses des cibles à ce Tick pour que le tir soit jugé contre ce qui était sur son écran.

L'instant de vue est une affirmation, pas un fait

Il vient du client, c'est donc une affirmation de l'appelant, et c'est ainsi qu'il est traité. Deux conséquences :

  • La fenêtre de compensation est bornée, et en dehors la plateforme refuse. Elle n'extrapole pas pour rendre service. Un refus est une décision que vous pouvez voir ; une extrapolation silencieuse est une décision que vous ne pouvez pas voir.
  • Lire l'état passé d'une cible obéit tout de même à la visibilité. Poser une question sur un Tick historique n'est pas un moyen de contourner Visibility — ce que vous ne pouviez pas voir alors, vous ne pouvez pas le lire maintenant.

Et « le coup n'a pas compté » est un verdict, pas une erreur : une réponse réussie portant une raison lisible par machine. Votre code a posé une question légitime et a reçu un non légitime.

Ce qui revient en arrière est déclaré, et ce n'est pas tout

Tout faire revenir en arrière semble cohérent et produit des double kills : deux joueurs se tirent dessus, tous deux sont rembobinés à un moment où tous deux sont vivants, tous deux touchent. Ne rien faire revenir en arrière annule la compensation de lag elle-même. La frontière entre les deux est une liste déclarée, pas l'intuition d'une implémentation.

Le rembobinage lui-même appartient ici plutôt qu'aux modules rembobinés. L'anneau d'historique restitue les poses du Tick en litige et l'on pose ensuite à Collision sa question ordinaire de chevauchement sur ces poses — la collision ne garde aucun historique à elle et rien en elle ne sait ce qu'est un Tick de vue. L'anneau lui-même est la piste d'historique de l'Entity, pas un second stockage.

La décision est prise sur le passé ; l'effet s'applique au présent

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

La compensation de lag répond à une question sur le moment de vue du tireur. Les conséquences — dégâts, mort, gain — s'appliquent à l'état courant. Ce qui s'est passé entre le moment de vue et le moment de la décision n'est ni annulé ni recalculé.

C'est donc observable, et c'est voulu : un joueur peut placer un tir après avoir été tué par le tir rembobiné de quelqu'un d'autre. Annuler cela reviendrait à rejouer le monde par-dessus un rembobinage qui ne promet pas la reproductibilité — ce qui fabrique de la divergence au lieu de la supprimer.

La re-simulation côté serveur est hors périmètre, délibérément. Recalculer les conséquences contre une nouvelle vérité exige un point de référence fixe que l'état en virgule flottante ne nous donne pas. Ce qui reste, c'est tout ce sur quoi le module repose : un client rejouant ses propres entrées non confirmées (Prédire votre propre mouvement), et la compensation de lag comme lecture du passé pour une seule décision. C'est ainsi que le favour-the-shooter fonctionne en pratique.

Si vous arrivez d'ailleurs

  • La compensation de lag favour-the-shooter telle qu'elle est livrée dans la plupart des shooters compétitifs : le même mécanisme, et cette page en est l'exposé.
  • Le netcode rollback complet. Le rembobinage est ici ; le rejeu du monde ensuite ne l'est pas, et le paragraphe ci-dessus explique pourquoi. Si votre conception dépend du recalcul des conséquences après coup, cette dépendance est la chose à nous signaler tôt plutôt qu'à découvrir tard.

Bon à savoir aussi

  • L'implémentation est redéfinissable. Si votre jeu a besoin d'une autre règle de compensation, vous pouvez remplacer la nôtre, et le remplacement déclare lesquelles des Declarations il honore.
  • Il n'y a pas de points d'extension sur le chemin de prédiction et de correction. Ceux-là s'exécutent à la cadence du Tick, et un Hook dans cette boucle serait un Hook que vous ne pouvez pas vous permettre.
  • Rien de tout cela ne s'applique sous autorité externe. La compensation de lag existe pour les Rooms que notre simulation exécute. Quand le Tick appartient au serveur de jeu d'un studio ou à un master-client, la compensation appartient à qui l'exécute — voir Qui conduit le Tick.
The live game

Bots

Un bot entre comme un joueur ordinaire. Seul le cerveau vit ailleurs. Même session, même validation d'entrée, mêmes règles, même ACL. La Room ne peut pas faire la différence, à dessein, si bien que les bots exercent les vraies règles de votre jeu et que l'anti-triche n'a jamais besoin d'une exception pour les bots.

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

Quand l'utiliser

  • Vos lobbies ont besoin d'être remplis aux heures creuses — FillRoom complète les parties jusqu'à un quota et les bots cèdent les sièges à mesure que des humains arrivent.
  • Les bots doivent jouer selon les vraies règles — validation d'entrée, ACL, Visibility — pour que l'anti-triche n'ait jamais besoin d'une exception pour les bots.
  • Vous apportez un cerveau externe — une politique apprise, un service — qui entre par ConnectAsBot comme n'importe quel joueur.
  • L'Entity d'un joueur déconnecté doit passer à un bot et revenir à la reconnexion, sans que le siège ni la Prediction s'en aperçoivent.
  • Passez votre chemin quand le personnage ne décide jamais — un PNJ de dialogue sans cerveau vit dans World Objects.

Cette page est la moitié « connexion ». Faire entrer un bot dans une Room, remplir un lobby jusqu'au quota, passer un siège entre un bot et un humain. Écrire la chose qui décide est l'autre moitié — Écrire un cerveau, qui spécifie la prise sur laquelle un cerveau se branche.

Qui fait quoi

ActorSur cette page
bot-brainse connecte comme joueur ; reçoit la perception ; envoie des commandes
room-ownerdéclare les profils, remplit les Rooms jusqu'au quota, passe la main bot/humain

En un coup d'œil

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

Un cerveau externe (IA plus lourde, politique apprise, service) se connecte comme n'importe quel joueur :

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

Le modèle

Un bot n'introduit aucune notion à lui — ni participant, ni canal d'entrée, ni zone de visibilité, ni comportement. C'est une credential d'Actor portant ce que Rooms, Data et Locomotion déclarent déjà.

Ce que porte la Declaration d'un bot.

DéclareCe que c'est
thinking tickà quelle fréquence les cerveaux sont sollicités, et ce n'est pas le Tick de simulation : les cerveaux s'exécutent à l'extérieur, et un appel réseau à chaque Tick est irréalisable
direction of the brainsoù ils s'exécutent — une fonction cloud, le backend du studio, son serveur de jeu. Lequel ne fait pas partie du contrat, et passer de l'un à l'autre n'est pas un changement cassant
actor presetles droits du bot, comme un preset d'Actor ordinaire
visibility of the bot markersi les participants en sont informés. Le marqueur lui-même existe toujours et la plateforme l'observe toujours ; que les joueurs le voient est une Declaration du type de Room, parce que sur certains marchés révéler un adversaire IA est une obligation et sur d'autres un choix produit
behaviour when the brains are unavailablel'un de trois, avec aucune valeur par défaut : do nothing · leave the room · fall back to built-in default behaviour
roster fillingdéclaré par le type de Room — combien, sous quelle condition, jusqu'à quel moment. Matchmaking ne sait rien des bots : il apparie des Actors, et ne décide pas avec qui compléter

Ce qui vaut pour tout bot.

ToujoursCe que c'est
an actor, not a playeril tient une credential d'Actor mais n'a pas de fournisseur de connexion, pas de liens et pas de sessions
economic ownershipest nulle — pas d'entitlements, pas d'achats, pas d'entrées de Leaderboard — sans quoi les bots finissent dans les classements et dans l'économie
perceptionest celle d'un joueur : la même zone de visibilité, le même prédicat, la même limite de nombre d'objets. Un bot et un joueur à la même position reçoivent le même ensemble d'objets, si bien qu'un bot ne peut pas voir à travers les murs plus qu'un joueur ne le peut
wider perceptionest un preset d'Actor, pas une propriété du bot : un mode de débogage ou de « coach omniscient » se déclare comme un preset avec un prédicat plus large
between thoughtsla dernière commande s'applique, et son sort est ce que le type de déplacement déclare déjà pour une entrée périmée — un bot dont le cerveau réfléchit est le même cas qu'un joueur dont le réseau a lâché
room capacitycompte un bot : il occupe un siège comme tout le monde

Erreurs

  • La Room n'accepte pas les bots est un conflit, et le répéter n'y changera rien.
  • La limite de bots est épuisée est un conflit, pas un forbidden — la permission d'en introduire un est tenue ; la Room est pleine. À retenter dès que la Room se libère.
  • Une commande pour un bot venant d'un Actor sans la permission répond forbidden, et répéter est inutile.
  • L'indisponibilité des cerveaux n'est pas une erreur — c'est l'un des trois comportements déclarés ci-dessus. Qu'ils aient été lents, en panne ou en train de réfléchir est l'affaire de la direction qui les exécute, et cela ne fait pas partie du contrat ; ce qui est observable est ce qui est observable pour tout participant.
  • Déclaré au déploiement, refusé au déploiement : un bot nommé propriétaire d'une entrée de Leaderboard, et un Tick de réflexion manquant, sont tous deux des échecs de validation au déploiement plutôt que des surprises dans une Room en direct.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Bots dans une Room — l'introduction est refusée comme conflit ; les bots existants ne sont jamais retirés pour faire de la place.
  • Bots par Project — le même conflit.
  • Le Tick de réflexion par le bas — une Declaration plus rapide que le plancher est refusée au déploiement, parce qu'un appel réseau par Tick est irréalisable.
  • La cadence de commandes pour un même bot — une limite de débit avec un délai.
  • L'échéance de réponse des cerveaux — une fois expirée, le comportement d'indisponibilité déclaré s'applique.

Parcours utilisateur

Un hôte complète le lobby jusqu'au quota, un cerveau externe prend l'un des sièges, et la Room tourne sur les vraies règles d'un bout à l'autre.

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

Écrire un cerveau

Un cerveau est du code ordinaire qui répond à une seule question : que fait ce bot ensuite. Il s'exécute là où vous voulez — une fonction cloud, votre propre service, un client sans affichage — et il parle à la Room par la même surface qu'emploie le client d'un joueur humain. Cette page spécifie la prise sur laquelle il se branche : ce qu'un cerveau reçoit, ce qu'il a le droit de renvoyer, et quand. Connecter un bot couvre l'autre moitié : faire entrer un bot dans une Room.

Ce qui est tranché, et ce contre quoi vous pouvez bâtir dès aujourd'hui

La plateforme ne livre aucune IA de jeu. Pas d'arbres de comportement, pas de système d'utilité, pas de cerveau de navigation. Ce n'est pas un trou en attente d'être comblé — c'est la frontière. Les décisions sont les vôtres, et le travail du module est de rendre vos décisions indiscernables de celles d'un joueur.

Un cerveau n'est pas un Hook. Un Hook enveloppe une de nos étapes. Un cerveau n'est pas du tout une de nos étapes : il s'exécute hors de la Room, à son propre rythme, et la plateforme se moque du sens dans lequel la connexion a été ouverte. C'est pourquoi un cerveau peut être une fonction cloud, un service que vous hébergez, ou un client sans affichage — et pourquoi aucun d'eux n'est plus natif que les autres.

La prise, c'est perception en entrée, commandes en sortie, et les deux côtés sont délibérément ceux du joueur :

Ce que c'est
perceptionexactement ce qu'un joueur à ce siège recevrait — les mêmes Deltas, à travers les mêmes règles de Visibility. Un bot ne peut pas voir à travers les murs plus qu'un joueur ne le peut.
commandesexactement ce qu'un joueur à ce siège enverrait. Aucun canal d'entrée privilégié n'existe.

Si un jeu a réellement besoin d'un bot qui voit davantage — un mode de débogage, un mode d'entraînement — c'est un élargissement déclaré, pas un effet de bord du fait d'être un bot.

Le Tick de réflexion est déclaré, et ce n'est pas le Tick de simulation. Les cerveaux sont à l'extérieur, ils réfléchissent donc à leur propre cadence. Entre deux pensées, la dernière commande tient — c'est la chose la plus importante à prendre en compte dans votre conception, car cela veut dire qu'un cerveau qui réfléchit lentement ne produit pas un bot immobile, il produit un bot qui continue de faire la dernière chose qu'il a décidée.

La disparition des cerveaux a un comportement déclaré, et il n'y a pas de valeur par défaut. Vous dites ce qui se passe quand le cerveau cesse de répondre, par type de Room. « Cerveaux indisponibles » est un Event retenu, si bien qu'un abonné tardif apprend la situation courante plutôt que seulement les changements à venir.

Ce qu'un bot ne peut délibérément pas être

À lire avant de concevoir autour, car ce sont des refus plutôt que des omissions.

  • Un bot n'est pas un joueur, et il ne possède ni entitlements, ni achats, ni enregistrements de Leaderboard. Un bot qui pourrait les tenir serait un moyen de les fabriquer.
  • Le marqueur de bot existe toujours et est toujours observable par la plateforme. Que votre jeu le montre aux joueurs est votre décision ; qu'il existe ne l'est pas.
  • Le module ne garde aucun historique de ce qu'un bot a décidé ni pourquoi. C'est votre affaire, dans votre télémétrie — nous n'allons pas devenir l'endroit où le raisonnement de votre IA est stocké.
Services around the game

Auth & Players

La connexion est une étape redéfinissable, pas une boîte noire. Fournisseurs, sessions, liaison d'identités, bannissements. Chaque point du flux — avant et après la connexion, avant et après une liaison, avant et après une fusion, sur un changement de statut — est un point d'extension déclaré avec un genre déclaré : une barrière qui peut refuser l'étape, ou un observateur qui ne le peut pas.

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Les joueurs doivent se connecter — appareil, e-mail, Apple, Google, Steam ou fournisseur personnalisé — avec la création à la première connexion comme drapeau, pas comme second flux.
  • Un compte invité doit pouvoir évoluer plus tard — Link ajoute Steam en conservant la progression, et les fusions réconcilient deux comptes en un seul joueur.
  • La politique doit s'exécuter là où on ne peut pas la sauter — une barrière régionale avant la connexion, un pack de démarrage après la connexion qui a créé le joueur.
  • La modération a besoin de mordant — révoquer des sessions, suspendre, bannir un appareil, avec un Event banned que chaque système en direct entend d'un coup.
  • Un contexte déclaré (région, plateforme, build) doit atteindre chaque Hook ultérieur sans que chacun ait à relire le joueur pour l'apprendre.
  • Il n'y a rien de plus léger vers quoi passer — chaque autre module nomme son appelant à travers celui-ci, et auth ne peut pas être coupé tant que l'un d'eux a besoin d'un Actor joueur : le configurateur de modules refuse, et nomme les dépendants.

Qui fait quoi

ActorSur cette page
playerse connecte, lie ou délie des identités, rafraîchit, se déconnecte
moderatorrévoque des sessions ; bannit, suspend ou restaure des joueurs
backend-servicefiltre la connexion par région ; sème les premières lignes d'un nouveau joueur ; lit et révoque des sessions

En un coup d'œil

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

Chaque point se personnalise là où il est déclaré ; les formes qu'un gestionnaire peut prendre sont rassemblées dans 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.

La forme déclarée est ce que le panneau rend : chaque point montre ses gestionnaires, leur genre et l'ordre résolu. Le genre est la partie qui a du mordant :

GenreQuand le gestionnaire lui-même échoueAu refus
une barrièrel'étape est refusée — un contrôle régional inatteignable n'est pas un contrôle régional réussiun code du catalogue de la plateforme plus une raison humaine. Les appelants branchent sur le code ; le texte de la raison est libre de changer et d'être traduit
un observateurl'étape reste faite, si bien qu'un pack de démarrage qui n'a pas abouti coûte un coffre, pas la connexionil ne peut pas refuser

Ce qu'aucun gestionnaire n'a le droit de faire, c'est de décider qui s'est connecté. Une barrière répond oui ou non au sujet d'une identité que la plateforme a déjà établie ; elle ne nomme pas le joueur, ne distribue pas d'identité, et ne tient pas lieu de confirmation du fournisseur. Cette ligne est la différence entre une connexion redéfinissable et une connexion contournable.

Le modèle

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

Un joueur est un porteur d'identité, pas une ligne dans votre schéma, et son identifiant est stable et jamais réutilisé — y compris lors d'une fusion : l'id d'un joueur fusionné continue de se résoudre au lieu de devenir une référence pendante. Une liaison est un triplet : fournisseur · sujet externe · joueur.

ToujoursCe que c'est
provider + subjectest unique, et cette unicité est source d'un conflit, non d'une interdiction — la réponse à « ce compte est déjà pris » est de choisir une fusion, pas de s'entendre dire non
at most one link per provider per playerun second compte du même fournisseur est un conflit
an external subjectn'est jamais l'identifiant d'un joueur : il appartient au fournisseur, et l'employer comme le nôtre lierait nos ids aux siens
identity kind and access statussont des axes différents : anonymous face à registered en est un ; active / suspended / banned en est un autre. Les confondre rend « anonyme banni » ou « inscrit suspendu » inexprimable
a session and a credentialsont des choses différentes : une session est l'enregistrement ; une credential est ce que vous présentez. Révoquer une session invalide toutes ses credentials et ferme ses abonnements ouverts
many simultaneous sessionschacune révoquée indépendamment
a credential's claimssont du contexte déclaré — région, langue — et du contexte seulement. Une claim ne porte jamais d'autorité
a device fingerprintn'est pas une identité : ce n'est jamais un motif d'admettre, seulement un motif de refuser, et elle est stockée et comparée sous une forme irréversible

Les trois machines.

DeÉtats
le genre d'identitéanonymous → registered, et la transition est à sens unique
le statut d'accèsactive ⇄ suspended, et active → banned → active pour un débannissement
le joueuralive → merged, où merged est terminal : un joueur fusionné ne se reconnecte pas

Ce que le consommateur déclare.

DéclareCe que c'est
sign-in policysi la connexion anonyme est autorisée, et le reste des règles autour de la connexion. Déclarée comme un attribut sur le point de montage du module — pas un fichier de configuration à côté du code, et pas construite à l'exécution
default rolel'ensemble qu'un nouveau joueur porte à la première connexion. Il n'y a pas de valeur par défaut pour la valeur par défaut : ne déclarez rien et les nouveaux joueurs arrivent sans aucun rôle, ce qui est une Declaration légitime plutôt qu'un oubli
session policyce qui se passe quand le plafond de sessions simultanées est atteint — évincer la plus ancienne avec un Event, ou refuser la nouvelle. Pas de valeur par défaut
deletion policycomment la suppression d'un joueur atteint les données qui le référencent

Où se configure un fournisseur. Dans le plan opérateur, pas dans le code — une credential de boutique n'a pas sa place dans un dépôt. Ce qui est déclaré atteint la console d'administration en lecture.

Ce que fait l'attribution d'un rôle. Les rôles ne sont pas seulement l'affaire d'un opérateur : la surface porte grant et revoke pour un joueur, si bien qu'un jeu peut promouvoir un officier de guilde ou remettre ses pouvoirs à l'hôte d'un tournoi depuis son propre code.

ToujoursCe que c'est
it is not self-promotionattribuer exige l'atome de permission déclaré pour cela, et un Actor sans cet atome obtient un simple forbidden plutôt qu'un silence sans effet
granting is idempotentattribuer un rôle que le joueur tient déjà est un succès, pas un conflit : l'état est l'ensemble des rôles, pas l'historique des appels, si bien que contrairement à la connexion cette opération n'a besoin d'aucune clé d'idempotence
revoking is not instantet nous ne prétendons pas le contraire. Cela prend effet sans réémettre la credential, et c'est observable au plus tard à la borne de péremption déclarée sur le cache des droits — un code qui attribue un rôle et le vérifie aussitôt sur un client connecté doit donc composer avec cette fenêtre
the default roleest déclaré par Project : l'ensemble qu'un nouveau joueur porte à la première connexion. Il n'y a pas de valeur par défaut pour la valeur par défaut — ne déclarez rien et les nouveaux joueurs arrivent sans aucun rôle, ce qui est une Declaration légitime plutôt qu'un oubli

Ce dont les rôles sont faits et ce qu'ils débloquent, c'est Access & Roles.

Erreurs

  • Pas de credential, ou une credential expirée, répond not authenticated et un rafraîchissement y remédie. Une credential révoquée répond de la même façon mais un rafraîchissement n'y remédiera pas : seulement une nouvelle connexion.
  • Une credential ayant subi une rotation présentée à nouveau est un conflit — c'est ce qui rend la rotation détectable plutôt que silencieusement tolérée.
  • Un joueur banni ou suspendu, et une empreinte bannie, répondent forbidden, et répéter est inutile.
  • La paire fournisseur+sujet est prise est un conflit, répétable après avoir choisi une fusion ; un second compte du même fournisseur est un conflit qu'un réessai ne changera pas.
  • Délier la dernière méthode de connexion est un refus de validation : cela laisserait un compte que personne ne peut atteindre.
  • Fusionner un joueur déjà fusionné est un conflit — merged est terminal.
  • L'indisponibilité du fournisseur répond unavailable et vaut la peine d'être retentée avec un backoff ; le fournisseur rejetant la credential répond not authenticated et vaut un réessai, pas une boucle. Confondre les deux ferait marteler par les clients un fournisseur qui a déjà dit non.
  • La cadence de tentatives de connexion dépassée répond dans la catégorie limite de débit, avec une échéance.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Sessions simultanées par joueur — selon la politique déclarée : éviction de la plus ancienne avec un Event, ou refus de la nouvelle. Il n'y a pas de valeur par défaut.
  • Tentatives de connexion par période, et tentatives de liaison d'une paire prise — une limite de débit avec une échéance, et le compteur de tentatives reste dans l'historique.
  • Liaisons par joueur — lier un fournisseur de plus est refusé comme conflit.
  • La durée de vie d'une credential — not authenticated, répétable par un rafraîchissement. La durée de vie d'une credential de rafraîchissement — seulement une nouvelle connexion.
  • La rétention d'un joueur anonyme sans connexion — suppression selon la politique déclarée, avec un Event. La politique est déclarée explicitement ; il n'y a pas de valeur par défaut.
  • Entrées dans la liste de bannissement d'empreintes — un ajout est refusé, et les anciennes entrées ne sont jamais évincées en silence.

Parcours utilisateur

Un compte invité au premier lancement, promu vers Steam plus tard en conservant la progression.

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Profile

Un profil est une vue, et la plateforme n'en possède presque rien. Ce que la plateforme garde d'un joueur est le player_id et le profil système derrière lui — identités, sessions, liaisons de fournisseur, tout cela dans Auth. Tout ce qu'un joueur possède est votre propre Entity, possédée par ce joueur. Un profil est l'ensemble de ces Entities que votre Project déclare, lu pour un propriétaire en une seule passe.

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Un écran a besoin de la tranche d'un joueur en un seul appel — l'ensemble déclaré se déploie à travers ses Entities possédées au lieu que le client recouse plusieurs requêtes.
  • Les surfaces de plateforme doivent montrer une personne, pas un identifiant — un Leaderboard, une file de modération et un ticket de support tiennent un player_id et rien d'autre tant que le Project n'a pas nommé l'enregistrement qui affiche un joueur.
  • Un autre joueur a besoin d'une fiche — la même lecture contre un autre propriétaire, restreinte par le prédicat de ligne et le masque de colonnes déjà déclarés dans Access.
  • Un HUD doit suivre en direct l'état possédé — la lecture est une sélection, et une sélection s'abonne.
  • Passez votre chemin quand la donnée n'appartient pas à un joueur — les lignes partagées et globales sont une sélection Entity ordinaire, sans propriétaire depuis lequel partir en fan-out.

Qui fait quoi

ActorSur cette page
schema-authormarque les Entities comme possédées par un joueur et déclare lesquelles forment le profil
playerlit son propre profil ; les écritures vont vers les Entities elles-mêmes
room-visitorlit le profil d'un autre joueur, dans la mesure où le prédicat et le masque de ce joueur l'autorisent

En un coup d'œil

L'appartenance est déclarée par Entity, pas par champ. L'Entity dit qu'elle appartient au profil ; ce qu'un autre joueur a le droit d'en voir est le masque de colonnes sur le rôle qui la lit (Access). Un attribut de vue au niveau du champ serait une seconde réponse à la question à laquelle l'accès répond déjà, et les deux dériveraient dès que quelqu'un en modifierait une.

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

La lecture est une sélection bornée au propriétaire — la surface de requête d'Entity avec le propriétaire fixé et la liste d'Entities prise de la Declaration. Profile est le nom de cette lecture, pas un module qui se tiendrait derrière : mêmes droits, mêmes prédicats, mêmes filtres, même abonnement, parce que c'est la même opération.

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

Le modèle

ConceptCe que c'est
player_idtoute l'idée que la plateforme se fait d'un joueur, plus le profil système derrière lui — Auth
ensemble de profilles Entities possédées par un joueur que le Project déclare comme son profil ; le déclarer est optionnel
sélection par propriétairela lecture : un propriétaire en entrée, ses lignes à travers l'ensemble en sortie — les mêmes droits, prédicats, filtres et abonnement que toute sélection Entity
lecture publiquecette sélection contre un autre propriétaire, restreinte par le prédicat de ligne et le masque de colonnes du rôle qui lit (Access)

Ce qui vaut pour toute lecture de profil.

ToujoursCe que c'est
there is no profile recordil n'a pas d'identifiant à lui, pas de Revision, pas d'historique et pas de cycle de vie, parce que c'est une vue sur des lignes qui ont les quatre
writes go where the data livesretouchez la ligne progress, et chaque lecture de profil qui l'inclut voit la nouvelle valeur à sa passe suivante
there is no public writeune vue n'a rien où écrire, et l'état partagé accessible en écriture passe par du code serveur
declaring the set is optionalet ne pas le déclarer n'est pas la même chose que déclarer un ensemble vide : un Project sans ensemble de profil n'a pas de lecture de profil du tout et l'appel est refusé comme indisponible, là où un résultat vide dirait que le joueur a un profil et qu'il se trouve être vierge
ownership is a predicatepas une colonne que la plateforme ajoute (Access) : owner == caller.player est une instance du mécanisme, « l'un des participants » en est une autre
a derived field belongs to a hookun observateur d'après changement est ce qui estampille Progress.Title quand Level franchit un seuil. Il suit l'écriture ; il ne peut pas la refuser

Erreurs

  • Un Project sans ensemble de profil déclaré n'a pas de lecture de profil, et l'appel est refusé comme unavailable — et non répondu par un résultat vide, qui dirait que le joueur a un profil et qu'il se trouve être vierge.
  • Une ligne qu'un prédicat cache répond not found, et une ligne qui n'existe pas de même : une lecture publique ne devient jamais un moyen d'apprendre ce qui existe mais n'est pas visible.
  • Un champ hors du masque du rôle qui lit est absent de la réponse, pas présent et vide.
  • Il n'y a pas d'écriture publique. Une vue n'a rien où écrire, l'état partagé accessible en écriture passe donc par du code serveur plutôt que par cette surface.
  • Une écriture pour le compte d'un joueur qui ne nomme pas le joueur est un refus de validation.
  • Déclarer l'ensemble de Profile est un acte de schéma — fn ou adm. Une clé de joueur qui s'y essaie reçoit forbidden, et le push est refusé en entier plutôt que déclaré à moitié.

Limites

Chaque plafond nomme son comportement au bord ; les nombres arrivent avec le chapitre des limites de la plateforme.

  • La taille de page de la sélection par propriétaire — rognée au plafond avec le drapeau « il y en a d'autres » toujours vrai ; en renvoyer moins sans le drapeau est interdit.
  • La taille d'un ensemble inclus par ligne — rognée par la même règle, avec le drapeau posé sur l'inclusion.
  • La cadence de changements sur une instance — un refus de limite de débit avec une échéance ; la lecture de profil est une sélection comme une autre et hérite des plafonds d'Entity plutôt que d'en déclarer à elle.

Parcours utilisateur

De la peinture du lobby au niveau qui passe : une écriture côté serveur atteint un écran abonné sans que l'écran redemande.

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Social

Un seul concept nouveau, et tout le reste est bâti à partir de ce que vous avez déjà. Une relation entre deux Actors, avec un état à elle et un initiateur — c'est tout ce que ce module ajoute. Un clan, une guilde ou une escouade est un Group avec une couche de relation par-dessus, pas un second genre de chose ; et le blocage, dont plusieurs modules ont besoin, vit ici pour qu'un seul endroit le possède.

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Les joueurs ont besoin les uns des autres par leur nom — amis, abonnements, listes de blocage.
  • Un clan ou une guilde a besoin d'une porte — une invitation venue du Group, une demande d'adhésion venue d'un Actor, et une décision sur l'une ou l'autre.
  • Une liste d'amis doit montrer qui est en ligne — la présence est dérivée des sessions, et qui a le droit de la voir est un prédicat que vous déclarez.
  • Un autre module doit savoir que quelqu'un est bloqué — il lit cet état ici plutôt que d'en tenir un à lui.
  • Passez votre chemin quand la chose est un ensemble d'Actors plutôt qu'une paire dotée d'un état : cela, c'est un Group, et un Group par paire voudrait dire des millions de Groups à deux personnes, chacun avec son cycle de vie et ses règles d'entrée.

Qui fait quoi

ActorPeutNe peut pas
playerproposer une relation ou suivre ; accepter, décliner ou retirer ; rompre une relation mutuelle ; bloquer et débloquer ; lire ses propres relations et la présence des Actors liés ; s'abonner aux changements ; soumettre une demande d'adhésionlire la liste de relations de quelqu'un d'autre, sous quelque relation de participant que ce soit
moderatordécider des invitations et des demandes d'adhésion là où il tient l'atome d'administration d'appartenancedécider d'une intention pour laquelle il ne tient aucune permission — cela répond forbidden

Le modèle

Ce que porte une Declaration de relation.

DéclareCe que c'est
kindsymétrique — la paire exige l'accord des deux côtés, et la machine à états ci-dessous porte là-dessus ; ou unilatérale — le suivi, dont le seul état est active. L'unicité par paire et l'idempotence d'une proposition valent pour les deux
re-invitation ruleaprès un refus : interdite · permise après une période déclarée · permise aussitôt. Déclarée, parce que « redemander » est une décision produit
presence visibilityun prédicat — à tous · seulement aux relations mutuelles · à personne. Il n'y a pas de valeur par défaut
joining mode (sur le type de Group)ouvert · sur demande avec décision · sur invitation seulement
retention of declined and brokenaprès la période déclarée la relation est supprimée, et réinviter redevient possible quelle que soit la règle de réinvitation

Les états d'une relation symétrique.

ÉtatSens
proposedl'initiateur a proposé et l'autre côté n'a pas répondu
mutualles deux côtés sont d'accord
declinedl'autre côté a refusé. La relation est conservée, parce que la règle de réinvitation a besoin de le savoir
brokenun côté a quitté une relation mutuelle
blockedun côté a bloqué l'autre

Ce qui vaut pour toute relation.

ToujoursCe que c'est
one entity per pairpas deux enregistrements en miroir. « A a proposé à B » et « B s'est vu proposer par A » sont un seul fait lu de deux côtés
an initiatorest déclaré : qui a proposé, ce dont l'affichage et la règle de réinvitation ont tous deux besoin
blocked dominatesde là il n'y a aucune transition vers proposed ni mutual
a blockest asymétrique en contrôle, symétrique en effet : seul celui qui l'a posé peut le lever, et il agit dans les deux sens
a refusal on a blockne le révèle pas : l'opération répond not found, si bien qu'un Actor bloqué ne peut pas découvrir le blocage en sondant
the block stateest possédé ici et consommé ailleurs : Messaging et d'autres le lisent ; aucun d'eux ne le mute, et aucun n'en garde de copie
presenceest dérivée des sessions : écrite par personne, et le prédicat de visibilité s'applique par demandeur plutôt qu'une fois par Actor
a deferred intentn'occupe pas de siège : une invitation ou une demande d'adhésion ne compte jamais dans la capacité du Group — sans quoi cent demandes épuiseraient un clan de cinquante et plus personne ne pourrait entrer
a groupconserve un administrateur : au moins un Actor doit tenir l'atome d'administration d'appartenance, et le dernier ne peut pas simplement partir : un clan dont le dernier administrateur s'en va ne pourrait plus jamais admettre personne
no intra-group roles« officier de clan » est un Actor tenant un atome, pas un grade stocké dans une liste
an import never overwritesles relations importées d'un fournisseur de connexion sont additives : quelqu'un de bloqué ne devient pas un ami parce qu'un fournisseur le dit

Erreurs

  • Déjà mutuelle est un conflit ; il n'y a rien à proposer.
  • Une proposition à soi-même est un refus de validation.
  • L'un des côtés a bloqué répond not found — pas forbidden, parce qu'un refus qui les distinguerait révélerait le blocage. Répéter est inutile.
  • Une réinvitation avant l'échéance est un conflit, à répéter après elle.
  • Une limite épuisée — relations, intentions — est un conflit, pas un forbidden : la permission est tenue, la place n'y est pas. Réessayez dès qu'une se libère, ou dès que les intentions existantes ont été décidées.
  • Une intention expirée est un conflit : créez-en une nouvelle plutôt que de retenter l'ancienne.
  • Le départ du dernier administrateur d'un Group est un conflit tant que la permission n'a pas été transmise.
  • Décider de l'intention de quelqu'un d'autre sans la permission répond forbidden, et répéter est inutile.
  • Un import depuis un fournisseur non connecté répond unavailable — réessayez avec un backoff.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Relations mutuelles par Actor — une proposition est refusée comme conflit ; les existantes ne sont jamais rompues pour faire de la place.
  • Relations unilatérales par Actor — une nouvelle est refusée ; les existantes restent.
  • Propositions sortantes — une nouvelle est refusée, et il n'y a pas d'éviction : une invitation évincée serait indiscernable d'une invitation déclinée.
  • Blocages par Actor — en ajouter un est refusé comme conflit, et les blocages plus anciens ne sont pas évincés ; quelqu'un débloqué en silence se remet à écrire et personne ne sait pourquoi.
  • La durée de vie d'une intention — expired, avec un Event.
  • La cadence de propositions par Actor — une limite de débit avec un délai.
  • La cadence de changements de présence dans le flux — bornée par la cadence de mise à jour plutôt qu'en écartant des changements.
  • La rétention des relations déclinées et rompues — suppression selon la période déclarée.

Parcours utilisateur

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Messaging

Rooms, Groups, joueurs : un seul modèle d'adressage pour le chat et les notifications. Les messages arrivent dans une conversation ; les conversations sont des Channels avec, en plus, de l'historique, de la modération et une livraison hors bande.

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Les joueurs se parlent — chat de Room, canaux de guilde, messages privés — par-dessus l'adressage que vous avez déjà : Room, Group, joueur.
  • Les joueurs hors ligne doivent tout de même entendre — des notifications gabaritées et planifiables livrent hors bande par push.
  • La modération doit s'exécuter avant la livraison — un Hook de pré-envoi filtre ou rejette, et le mute/blocage est appliqué par la plateforme partout.
  • Les joueurs qui reviennent ont besoin de rattraper — History(take: 50) pagine la conversation au lancement suivant.
  • Passez votre chemin quand la charge utile est de l'état de jeu, pas de la conversation — les champs synchronisés de Data et les Channels de Core diffusent déjà cela.

Qui fait quoi

ActorSur cette page
playerenvoie et reçoit des messages ; lit l'historique ; met en sourdine ou bloque
moderatorfiltre, caviarde et bannit des termes
backend-serviceenvoie ou planifie des notifications gabaritées

En un coup d'œil

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

Un message structuré est un Event déclaré, et la conversation le porte ensuite par son nom — pas de classe de charge utile à construire sur le site d'appel :

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

Le message déclaré arrive typé dans le même abonnement, si bien qu'un client qui connaît RallyCall obtient des champs plutôt qu'un bloc opaque.

Les notifications sont hors bande, gabaritées et planifiables — et elles sont envoyées depuis l'autorité fn ou adm, jamais depuis une session de joueur :

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

La modération sous forme de Hooks, même contrat que partout :

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Le modèle

Ce qu'un type de conversation déclare.

DéclareCe que c'est
the groupsa liste — un participant est un Actor, exactement comme dans Groups
binding to a lifetimeoptionnellement celle d'une autre Entity, si bien qu'un chat de Room disparaît avec sa Room

Où passe la ligne entre l'enveloppe et la charge utile.

PartieÀ qui elle est
envelopeà la plateforme : l'auteur, la conversation, l'instant selon l'horloge déclarée
payloadau studio, déclarée comme un type de message avec des champs typés, et apparaissant comme sa propre surface d'envoi plutôt que comme un sac non typé

Trois choses que ce module possède et qu'un simple Event n'a pas — l'ordre à l'intérieur d'une conversation, la période de rétention, et le sujet de la modération. C'est pourquoi le chat n'est pas « un Event avec de l'historique » : l'ordre, l'historique et la modération d'un joueur sont ici l'affaire de la plateforme et ne sont pas dans Events.

Les trois machines.

DeÉtats
une conversationcreated → active → closed
un messagesent → published | rejected by the filter, puis édité ou supprimé, de façon observable
une notificationcreated → queued → delivered | expired

Ce qui vaut pour tout message.

ToujoursCe que c'est
order within a conversationest stable et déclaré. L'ordre entre conversations n'est pas promis
editing and deletingsont observables : un message ne disparaît jamais en silence — sinon l'historique d'un client et celui du serveur divergent sans que personne le sache
historyce sont les messages eux-mêmes : avec une période de rétention déclarée, lus par pages via un curseur depuis une position
retention outlives the complaint windowla période n'est pas plus courte que le temps accordé pour traiter une plainte : une plainte arrive après le message, et un message qui n'existe plus ne laisse rien à traiter
read stateest une position, pas un drapeau : une position par Actor et par conversation, et marquer comme lu est monotone — la position ne décroît jamais, si bien qu'un appel répété ne peut pas défaire une progression. Le compte de non-lus est une dérivée de cette position plutôt qu'un compteur à lui
sendingest idempotent par clé : deux appels sont deux répliques, la clé est donc ce qui rend un réessai sûr
blockingest un prédicat de livraison, pas un refus d'envoyer : l'émetteur n'en est pas informé, parce qu'un refus révélerait le blocage. L'état lui-même vit dans Social
the sender composes the payloadla plateforme ne lit pas les données du destinataire pour remplir votre texte. La langue du destinataire peut être une claim de contexte déclarée qui voyage jusqu'au point d'extension, si bien que la substitution et la traduction sont le travail du Hook — le seul endroit qui connaît à la fois le destinataire et sa langue
the delivery routene fait pas partie du contrat : push, dans l'application, ou autre chose est une décision de routage, pas une promesse
deliveryest observable dans des bornes déclarées : « en file » toujours ; au-delà, dans la mesure où la route peut le rapporter

Chaque point d'extension nomme le type qu'il remet au Hook : le message sortant avant publication, le message publié après. Un filtre peut corriger le contenu qui lui a été remis — masquer un mot est une correction — mais jamais l'émetteur ni la conversation.

Erreurs

  • Une conversation qui n'existe pas ou qui est cachée, et un Actor qui n'est pas participant, répondent tous deux not found — un refus ne révèle donc jamais une conversation où vous n'êtes pas.
  • Une conversation fermée est un conflit.
  • Pas de permission d'écrire dans ce type répond forbidden, et répéter est inutile.
  • Le rejet par le filtre est un verdict, pas un refus. L'appel a été effectué, le contenu a été examiné, la décision est négative et la raison est une valeur déclarée — c'est pourquoi il est distinguable d'un refus par permissions, et pourquoi la suite dépend de la raison.
  • L'indisponibilité du filtre répond unavailable et vaut la peine d'être retentée avec un backoff — mais rien n'a été publié entre-temps.
  • Un type de message non déclaré pour cette conversation, et un message surdimensionné, sont des refus de validation ; le contenu n'est jamais tronqué en silence.
  • La cadence d'envoi dépassée répond dans la catégorie limite de débit, avec une échéance.
  • Éditer le message d'un autre répond forbidden.
  • Une notification au-delà de son expiration est un conflit : envoyez-en une nouvelle.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Taille des messages, et pièces jointes avec leur taille — l'envoi est refusé comme échec de validation, jamais tronqué. Les fichiers eux-mêmes appartiennent à Files & UGC.
  • La cadence d'envoi par Actor — une limite de débit avec une échéance.
  • La profondeur d'historique — au-delà de la période, un message est évincé de la rétention avec un Event, plutôt que de disparaître discrètement.
  • Conversations par Actor — en rejoindre une de plus est refusé comme conflit.
  • Notifications en file par Actor — une nouvelle est refusée, et l'éviction est interdite : une notification écartée en silence est indiscernable d'une notification jamais envoyée.
  • L'expiration d'une notification — expired, avec un Event.
  • Les participants d'une conversation relèvent de la limite de Groups, et les blocages par Actor de celle de Social — ni l'une ni l'autre n'est redite ici.

Parcours utilisateur

Un message de ralliement atteint toute la guilde. Deux rôles se partagent la livraison : le online-member, qui est dans la conversation au moment où il arrive, et le offline-member, qui reçoit un push et lit le ralliement dans l'historique au lancement suivant.

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Catalog & Commerce

Articles, prix, portefeuilles, boutiques, achats, entitlements. De vraies intégrations de boutiques là où les plateformes les permettent (Stripe, App Store, Google Play, Steam, Xbox) ; des boutiques planifiées et ciblées par audience ; et un flux d'achat dont chaque étape est accrochable.

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Vous vendez des choses — contre de l'argent réel via Stripe, App Store, Google Play, Steam ou Xbox, ou contre la monnaie du portefeuille.
  • Les boutiques doivent se résoudre par joueur — planning, audience et prix calculés côté serveur, jamais un calcul d'éligibilité dans le client.
  • Les règles de tarification appartiennent à un seul Hook testable — remises, retarifications et vetos s'exécutent avant tout débit.
  • Les reçus doivent être à l'épreuve du rejeu, et un remboursement doit révoquer l'entitlement par les mêmes Events qu'a employés l'attribution.
  • Passez votre chemin quand rien n'est jamais vendu — bien que les récompenses atterrissent tout de même par l'unique Grant de commerce avec l'origine reward (les coffres de cycle de Leaderboards arrivent ainsi), si bien que même un jeu sans boutique garde un registre d'attributions unique et auditable.

Qui fait quoi

ActorSur cette page
playerparcourt les boutiques, achète, gère son portefeuille, échange des codes
sellerconfigure le catalogue, les prix et les plannings de boutique
backend-servicevalide les reçus ; retarifie ou attribue via les Hooks d'achat

En un coup d'œil

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

Le modèle

Ce qu'un article de catalogue déclare.

DéclareCe que c'est
keyc'est du contenu créé, adressé par une clé, si bien qu'un renommage dans le code est un renommage
kindconsumable — il se dépense ; ou durable — possédé une fois
pricesun prix est une quantité monétaire : un entier en unités mineures plus un code de devise, jamais un flottant. Un article peut en porter plusieurs — monnaie de jeu et monnaie réelle à la fois
external identifier per providerun emplacement par fournisseur, déclaré, parce qu'une boutique externe connaît l'article par son propre id
what it points atoptionnellement une Entity de n'importe quel genre déclaré, et acheter l'article accorde alors la propriété de cette Entity

Ce que la composition a le droit d'être.

Ce que c'est
what is purchasableun article de catalogue, jamais une Entity arbitraire : un prix que personne ne sert n'est pas une promesse, parce qu'un achat a besoin de quelqu'un qui accorde le droit et réponde du remboursement
two levels, no thirdun bundle est un article de catalogue fait d'articles ; une boutique est un ensemble d'offres, et une offre pointe vers un article et peut redéfinir son prix et le contenu d'un bundle
a territorial prices'exprime par une boutique plutôt que sur l'article

Ce qu'une boutique déclare.

DéclareCe que c'est
offersl'ensemble, chacune pointant vers un article
scheduleen temps d'horloge, toujours en UTC : quand la fenêtre ouvre et ferme
audienceun prédicat, pas une liste de joueurs — si bien que l'audience est une règle qui continue d'être vraie plutôt qu'un instantané

Une boutique dont un joueur ne fait pas partie de l'audience n'existe pas pour ce joueur.

Les états d'une commande.

DeVers
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid ou grantedrefunded
ToujoursCe que c'est
the priceest fixé dans la commande au moment de sa création, si bien qu'un changement de prix ultérieur ne peut pas altérer ce qui a été convenu
awaiting paymenta une échéance déclarée, déclarée par fournisseur, parce qu'ils diffèrent
grantingest séparé du paiement : paid et granted sont des états différents : l'argent qui arrive et la chose qui apparaît sont deux faits, et les confondre cache lequel a échoué
a refundest une transition externe : elle arrive sans aucune demande de notre part, à tout moment, et ce qui advient de ce qui avait été accordé est déclaré — il y a trois réponses et aucune valeur par défaut
an entitlementporte son origine — un achat, un code promo, une récompense, un cadeau — si bien que « d'où cela vient-il » a une réponse un an plus tard
a consumable entitlements'accumule : il change par un incrément avec une clé d'idempotence, jamais en écrasant ce qui a été lu
ownershipest un prédicat de propriétaire : un entitlement appartient à un joueur par le même mécanisme que toute ligne possédée
the catalogest déclaré en code et atteint le panneau sous le mode de propriété seed par défaut : le code crée ce qui est absent, et les retouches d'un game designer survivent au push suivant
provider secretsvivent dans le plan opérateur, jamais dans la Declaration, et jamais dans un dépôt
a provider's capabilitiessont déclarées : si elle a une API utilisable tout court, et ce qu'elle sait faire — pour qu'un catalogue ne promette pas un flux que la boutique du fournisseur ne peut pas servir

Chaque point d'extension nomme le type qu'il remet au Hook : une intention d'achat avant l'achat — joueur, offre, fournisseur, prix — et l'achat lui-même après. Un Hook ne reçoit jamais de sac non typé.

Erreurs

  • Hors de l'audience répond not found, et répéter est inutile. Hors du planning répond également not found, mais vaut la peine d'être répété une fois la fenêtre ouverte.
  • Le fournisseur est indisponible et le fournisseur a refusé le paiement sont délibérément des réponses différentes : la première est unavailable et retentable avec un backoff, la seconde un conflit qu'un réessai ne réglera pas. Les confondre ferait retenter un refus indéfiniment.
  • Un reçu invalide est un refus de validation ; un reçu déjà consommé par une autre commande ou un autre joueur est un conflit — c'est ce qui rend le rejeu inutile.
  • Le prix a changé entre la lecture de la boutique et l'achat est un échec de précondition : relisez et décidez à nouveau, plutôt que d'être débité du nouveau prix en silence.
  • Une monnaie de jeu insuffisante est un conflit, pas un forbidden — la permission d'acheter est détenue, le solde n'y est pas. À répéter après avoir rechargé.
  • Un entitlement durable déjà tenu est un conflit.
  • La région ou l'âge ne permettent pas l'achat répond forbidden, et répéter est inutile.
  • L'échéance de la commande est passée est un conflit : créez une nouvelle commande.
  • La limite de dépense épuisée répond comme conflit ou comme limite de débit selon la limite dont il s'agissait, et elle énonce quand la limite se réinitialise.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • La taille du catalogue — publier un article de plus est refusé comme conflit.
  • Boutiques par Project — la création est refusée.
  • Offres dans une boutique — un ajout est refusé ; la boutique n'est jamais tronquée en silence.
  • La durée de vie d'une commande en attente de paiement — un passage à expired, avec un Event.
  • La cadence de tentatives d'achat — une limite de débit avec une échéance.
  • La limite de dépense par période — un conflit qui énonce quand la limite se réinitialise.
  • La rétention des commandes — au-delà de la période, une commande devient illisible par la période déclarée plutôt que de disparaître sans explication.
  • Entitlements par joueur — une attribution est refusée, et ceux déjà accordés ne sont jamais évincés.
  • La précision d'un prix n'est pas une limite mais un type — un entier en unités mineures.

Parcours utilisateur

Le premier achat d'un nouveau joueur : la boutique se résout, le prix est divisé par deux, l'article atterrit — et la vente atteint l'entonnoir de premier achat que l'operator lit dans Analytics.

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Inventory

Tout s'intègre ici. Les tirs débitent les munitions, les drops y atterrissent, les abilities le consultent, le mouvement en est modifié — un seul ensemble de lignes possédées, avec des piles qui s'incrémentent et un plafond par propriétaire dont vous choisissez le comportement au bord.

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

Quand l'utiliser

  • Les joueurs détiennent des choses, et une détention est une ligne avec un propriétaire — lue par propriétaire, plafonnée par propriétaire, avec le comportement de débordement déclaré plutôt que laissé à une valeur par défaut.
  • Une quantité s'accumule — une pile change par un incrément avec une clé d'idempotence, si bien qu'un débit retenté ne débite pas deux fois.
  • D'autres modules dépensent depuis un seul ensemble — les tirs débitent les munitions, les drops accordent du loot, les achats apparaissent comme des lignes face à leur entitlement.
  • Passez votre chemin quand le nombre n'est pas possédable — les HP, l'XP et les temps de recharge appartiennent aux stats.

Qui fait quoi

ActorSur cette page
playerlit ses propres détentions et dépense depuis elles
backend-serviceaccorde, incrémente et révoque pour le compte d'un joueur, en nommant le joueur pour lequel il agit

En un coup d'œil

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

Une session de joueur exécute les lectures, les déplacements et la vérification de solvabilité avec les mêmes appels. Accorder, consommer et détruire ne sont pas à elle : la plateforme les refuse comme interdits et nomme le droit qui manque à l'appelant, quel que soit le binding qui a fait l'appel.

Le modèle

Un inventaire n'introduit aucune notion à lui. C'est un preset — une forme assemblée à partir de ce qu'Entity donne déjà, si bien que tout ce qui suit est une Declaration d'Entity plutôt qu'un mécanisme de cette page. Un preset qui aurait besoin d'un genre nouveau de Declaration serait un trou dans le contrat, pas une raison d'étendre le preset.

DéclareCe que c'est
un type possédéla détention appartient à un propriétaire, et la sélection par propriétaire est l'opération propre à Entity
un ref vers un article de cataloguela référence stocke l'id de l'article et jamais sa key, ce qui est exactement ce qui rend le renommage d'une clé sûr. La définition elle-même vit dans Commerce
un aspect de pile avec un incrémentune pile change par un Delta plutôt qu'en écrasant ce qui a été lu. L'incrément n'est pas idempotent par nature — deux incréments font deux incréments — il est donc obligé d'accepter une clé d'idempotence, et le moyen d'établir l'issue est une lecture adressée
un plafond par propriétaire avec son comportement au bordl'un de trois, et il n'y a pas de valeur par défaut : refuse · redirect vers un bac de propriétaire déclaré · discard with event

Ce qui vaut pour toute détention.

ToujoursCe que c'est
the cap has no defaultles trois réponses à un sac plein sont trois jeux différents : un refus perd le butin devant le joueur, une redirection est du courrier ou un entrepôt qui déborde, un rejet est une perte silencieuse, licite seulement parce qu'elle a été déclarée et qu'elle est observable. Aucune n'est juste pour les trois, la déclaration tranche donc
the owner is immutablerien ne change de mains en modifiant un champ : une détention se déplace comme une révocation plus une nouvelle attribution à l'origine déclarée, et les deux faits restent au dossier. Modifier le propriétaire effacerait la trace et laisserait « d'où me vient ceci » et « on me l'a pris » sans rien derrière l'état courant
a transfer between two playersest une autre promesse : elle exige un séquestre et de l'anti-fraude, et elle est hors de cette version
a rowaffiche un entitlement plutôt que d'en être une seconde source — ce qui a été acheté vit dans Commerce, et la ligne ici le représente

Erreurs

  • L'instance n'existe pas, ou un prédicat la cache — la réponse est not found dans les deux cas, si bien qu'un refus ne révèle jamais que quelque chose existe mais n'est pas à vous.
  • Un champ non déclaré dans l'aspect (imbriqués compris), et un champ obligatoire sans valeur, sont des refus de validation nommant le champ.
  • La version ne correspondait pas est un échec de précondition, à répéter après relecture.
  • Une écriture pour le compte d'un joueur qui ne nomme pas le joueur est un refus de validation, pas une écriture silencieuse au nom de quelqu'un d'autre.
  • Pas de permission pour une lecture ou une écriture répond forbidden, la lecture et l'écriture étant distinguées.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Instances par propriétaire — selon la règle déclarée ci-dessus, et il n'y a pas de valeur par défaut.
  • La taille de l'instance stockée — l'écriture est refusée comme conflit, et le refus nomme le champ fautif et la taille mesurée. Le plafond est atteint par accumulation, si bien que l'approche est observable avant l'écriture qui échoue.
  • La cadence de changements sur une instance — un refus de limite de débit avec une échéance.
  • La taille de page d'une sélection — la page est rognée au plafond et le drapeau « il y en a d'autres » reste vrai ; en renvoyer moins sans le drapeau est interdit.

Parcours utilisateur

Les munitions d'un tir, du lancement qui les débite jusqu'au drop de caisse qui les rend. L'ability, le projectile, le bloc de Stats de la caisse et la table de drop qu'elle contient sont des entity presets — des Declarations sur des Entities, pas des modules à part entière.

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Leaderboards

Chaque mécanique, systématisée. Pas un catalogue de types de tableaux. Un seul modèle dont les axes se composent pour donner tous les autres : classements quotidiens, tableaux du meilleur tour, totaux de guilde, saisons, tournois.

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

Lisez ce bloc ainsi : qui agit sur cette page (actors), ce que le module vous remet (provides), sur quels modules il se tient (builds-on), et où il s'accroche à la racine — mounts: root veut dire playserv.Leaderboards, pas un espace de noms sous un autre module (comment les modules se montent).

Quand l'utiliser

  • Des scores doivent classer des joueurs — classements quotidiens, tableaux du meilleur tour, totaux de guilde — comme un seul modèle déclaré, pas un système par tableau.
  • Vous avez besoin des lectures standard — top-N, autour de moi, une liste nommée de propriétaires — sans modélisation de données supplémentaire.
  • Les cycles doivent se fermer à l'heure, archiver (jamais supprimer) et déclencher un Hook de récompense avec le tableau final.
  • Les scores suspects ne doivent jamais entrer dans le tableau — un Hook de pré-soumission valide, plafonne ou rejette avec une raison typée.
  • Un tournoi est le même tableau avec une fenêtre d'inscription, un maximum d'inscrits et des tentatives par cycle.
  • Passez votre chemin quand le nombre n'est jamais comparé entre joueurs — un compteur personnel ou un total de carrière est de la data ordinaire. Le module ordonne des résultats ; il ne les calcule jamais, et il n'exécute aucun tableau à élimination.

Qui fait quoi

ActorSur cette page
playerlit le top-N / autour de moi / son propre rang, s'abonne aux changements de rang
backend-servicesoumet des résultats ; les corrige ou les rejette dans le Hook de pré-soumission ; accorde les récompenses à la fermeture d'un cycle
operatordéclare les tableaux ; ferme un cycle par anticipation, corrige des enregistrements (audité), surveille les cadences de soumission

En un coup d'œil

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

La Declaration vit à côté du reste de votre schéma — dans le projet serveur, ou dans le projet UE ou Unity — et playserv push la compile et l'envoie : le tableau apparaît dans le panneau, vide, avec sa prochaine réinitialisation déjà planifiée. Les plannings sont en UTC, ce tableau ferme donc lundi 00:00 UTC ; Reset.Weekly(DayOfWeek.Monday, at: "03:00") déplace l'heure. L'heure locale par joueur n'est pas une option de réinitialisation — un même tableau ne peut pas fermer à vingt-quatre moments différents.

La clé d'ordre est une liste, pas un score plus un départage. Les champs classent dans l'ordre où vous les numérotez, chacun avec sa propre direction, et le dernier palier est celui de la plateforme : à clés égales, la soumission la plus ancienne est mieux classée, si bien que deux courses identiques n'échangent jamais leurs places entre deux lectures. Un champ hors de la clé — Map ici — est porté pour l'affichage et ne déplace jamais une ligne.

Agg dit ce qu'une seconde soumission fait à l'unique enregistrement qu'un propriétaire a dans le cycle courant :

AggUne seconde soumissionIdempotent
Setremplace l'enregistrement par les valeurs soumisesoui
Bestne le remplace que lorsque les nouvelles valeurs se classent plus haut selon la clé d'ordreoui
Incrementajoute les valeurs soumises à l'enregistrement — kills, tours, contribution de guildenon — portez une clé d'idempotence
Decrementles soustraitnon — portez une clé d'idempotence

Une soumission qui ne bat pas un enregistrement Best n'est pas une erreur : elle revient acceptée, l'ordre inchangé. Increment et Decrement sont les deux qu'un appel retenté appliquerait deux fois, ils prennent donc la même clé d'idempotence que toute autre écriture retentable.

Soumettre tient en un appel, et sur ce tableau il vient du code serveur parce que la Declaration l'a dit :

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

Trois choses dans cet appel méritent d'être lues séparément :

Dans l'appelCe que c'est
PlayServ · playservle handle de fonction cloud et l'instance cliente que le SDK vous remet au démarrage. La même API, deux appelants — en Go ce sont ps et psv, et chaque snippet utilise celui dont dispose son appelant
celui qui soumetla fonction propriétaire du résultat du match. Dans Tanks c'est le Hook on dispose de la Room (Rooms), qui tourne avec l'état final en main
playerIdl'id de joueur de la plateforme, venu d'Auth & Players, jamais un nom que vous avez choisi : un Hook le lit sur son payload (e.By.PlayerId dans la leçon), et un host de Room soumet l'id du siège qu'il possède

Les valeurs sont les champs que la Declaration a nommés — un champ non déclaré est refusé, pas stocké.

Les lectures dont chaque jeu a besoin, et l'abonnement qui les tient à jour :

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) est une fenêtre par rang, pas une page : cinq lignes au-dessus de vous, cinq en dessous, plus la vôtre — onze lignes, rognées symétriquement là où le tableau s'arrête, si bien que le rang 2 obtient une fenêtre plus courte des deux côtés plutôt qu'une fenêtre décalée. Top est paginé : il renvoie les N premières lignes et un curseur, et after: parcourt le reste.

ForOwners est la façon dont fonctionne un tableau d'amis. La plateforme ne tient aucun graphe d'amis ; vous passez les propriétaires que votre jeu a déjà — les membres d'un Group, ou une liste d'ids venue de vos propres données — et chaque ligne revient avec son rang dans le tableau complet, pas un rang à l'intérieur de la liste.

OnRankChanged livre le rang du joueur local et rien d'autre : un tableau de cinquante mille inscrits ne pousse pas chaque remaniement vers chaque client. Le callback reçoit la ligne modifiée — rang, champs classés, champs d'affichage — et Cancel() met fin à l'abonnement. Le rang lui-même est un instantané : deux lectures à une seconde d'intervalle peuvent différer pendant que des soumissions atterrissent, bien que votre propre soumission soit toujours visible à votre propre lecture suivante.

Le modèle

Ce qu'un tableau déclare.

AxeValeursComment vous le réglez
Propriétairejoueur · GroupOwner = Owner.Player — un tableau de guilde est le même tableau avec Owner.Group
Clé d'ordreun ou plusieurs champs déclarés, chacun croissant ou décroissant[Rank(1, Sort.Descending)] int Score
Agrégationset · best · increment · decrementAgg = Aggregation.Best
Réinitialisationun planning en UTC ; un cycle expire, ne supprime jamaisReset = Reset.Weekly(DayOfWeek.Monday)
Qui a le droit de soumettreserveur seulement (la valeur par défaut) · joueursSubmit = Submit.ServerOnly
Champs d'affichagedéclarés et typés ; jamais partie de l'ordre[Display] string Map
Liste de propriétaireschoisie à la lecture, non déclaréeForOwners("weekly-score", ids) — amis, guilde, lobby
Règles de tournoifenêtre d'inscription · inscrits maximum · tentatives par cycle · adhésion requiseRules = Tournament.Define(…), dans le tableau sous Tournois

Il n'y a pas d'axe de portée : un tableau par région, par Room ou par saison est un tableau par clé, et la clé est ce que votre code référence.

Ce qui vaut pour tout tableau.

ToujoursCe que c'est
direction and operatorsont immuables après la première écriture : les changer reclasserait silencieusement l'historique ; la façon de changer une mécanique est une nouvelle génération, pas une retouche
exactly one entry per owner per generationune seconde n'est pas une seconde ligne
an entryn'est pas une Entity : pas de cycle de vie à elle, pas de machine : elle est créée par la première soumission et modifiée par l'opérateur que le tableau a déclaré
fields outside the order key never affect the orderce sont des champs d'affichage, et c'est pourquoi ils sont déclarés séparément
a generationexpire, elle ne supprime pas : open → expired → evicted from retention, et les générations expirées restent lisibles pendant la période de rétention déclarée
the schedule transitionest observable par un Event, si bien qu'un gestionnaire lit exactement le tableau qui s'est fermé plutôt que celui, vide, qui vient de s'ouvrir
the default submitterest le serveur : qui a le droit de soumettre est déclaré, et la valeur par défaut n'est pas le joueur
a boardest du contenu écrit : déclaré en code, adressé par une key, atteignant la console d'administration, sous le mode de propriété seed pour que les retouches de planning d'un game designer survivent au push suivant
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

Ce qu'est un cycle, et ce que sa fermeture fait.

Ce que c'est
a resetferme un cycle plutôt que de le supprimer
a closed cyclecesse de prendre des soumissions et reste lisible sous son étiquette — Top("weekly-score", 100, cycle: label), un paramètre de lecture plutôt qu'un travail d'export
the close eventporte cette étiquette, si bien qu'un gestionnaire lit exactement le tableau qui s'est fermé et non celui, vide, qui vient de s'ouvrir

Les deux Hooks d'un tableau, et leurs genres diffèrent.

HookCe qu'il a le droit de faire
pre-submitun gatekeeper : la plateforme l'appelle et attend. Il peut corriger les valeurs soumises au regard de vos propres Entities, les plafonner, ou rejeter avec une raison typée, et s'il échoue la soumission est refusée — fail-closed. Il n'a pas le droit de changer le propriétaire de l'enregistrement ni son tableau : ceux-là sont déjà revendiqués. Il rend un verdict — accepter, accepter une soumission corrigée, ou rejeter — et le rejet parvient à l'appelant comme un problème typé (Core), la même forme que prend chaque refus dans le SDK
cycle-closedun observer : déclenché après coup, il ne peut pas opposer de veto, et un échec là laisse le cycle fermé
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

La récompense est une attribution Commerce plutôt qu'une mécanique de ce module : chest.gold est un id de catalogue, et l'origine reward est ce qui sépare l'attribution d'un achat — les remboursements, la révocation et l'Event de changement d'entitlement s'appliquent exactement comme sur un article acheté.

Tournois. Un tournoi est ce tableau plus des contraintes de participation — il n'y a pas de second mécanisme ni d'Entity séparée. Les quatre contraintes, avec leurs unités et leur comportement au bord :

ContrainteDéclarée commeAu bord
fenêtre d'inscriptionentryWindow: TimeSpan — combien de temps l'adhésion reste ouverte après l'ouverture du cycleune adhésion après sa fermeture est refusée ; le cycle continue jusqu'à sa réinitialisation
inscrits maximummaxEntrants: int — enregistrements dans un cyclele 65ᵉ inscrit sur 64 est refusé comme conflit, et rien n'est évincé — un tableau qui laisserait tomber ses pires lignes classerait celui arrivé en premier
tentatives par cycleattemptsPerCycle: int — soumissions par propriétairela soumission suivante répond « tentatives épuisées » — un conflit, pas une erreur de permission, et le compteur se réinitialise avec le cycle
adhésion requisejoinRequired: true — les inscrits sont une appartenance, pas tous ceux qui jouentune soumission venant d'un non-inscrit est refusée

Un tournoi quotidien déclare les quatre de bout en bout.

Erreurs

Une session player appelant une opération que ce tableau réserve à fn — une soumission vers un tableau réservé au serveur, une fermeture anticipée de cycle — est refusée comme erreur de permission avant que quoi que ce soit ne soit écrit ; le même appel depuis une fonction cloud passe. Une soumission dans un cycle déjà fermé est un conflit à la place : le droit est là, le cycle ne l'est pas, et le réessai est une soumission dans le cycle courant.

Limites

Chaque limite avec ce qui se passe à son bord.

LimiteAu bordNombre
lignes par lecturela page est rognée, « il y en a d'autres » reste vrai, after: continueplafond de page fixé par Project
fenêtre autour d'un propriétairerognée symétriquementplafond de fenêtre fixé par Project
enregistrements dans un cyclela soumission est refusée comme conflit ; pas d'évictionmaxEntrants par tableau ; sans borne si non réglé
tentatives par propriétaire et par cycleconflit « tentatives épuisées », levé par la réinitialisationattemptsPerCycle par tableau ; sans borne si non réglé
cadence de soumission par propriétairerefus de limite de débit portant le moment où un réessai est permiscadence fixée par Project
tableaux par Projectune nouvelle Declaration est refusée au déploiementlimite fixée par Project
rétention des cycles fermésle cycle quitte le stockage avec un Event ; les lectures répondent alors not-foundfenêtre de rétention fixée par Project

Parcours utilisateur

Une semaine du tableau weekly-score : des soumissions côté serveur, une lecture autour de moi, la fermeture du lundi et ses récompenses.

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Files & UGC

Les fichiers arrivent par morceaux et sont traités à mesure qu'ils arrivent. Uploads, assets et leurs variantes dérivées, et contenu généré par les joueurs avec un chemin de modération.

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

Quand l'utiliser

  • Des joueurs ou des services téléversent des blobs — sessions découpées et reprenables avec des quotas par joueur lisibles.
  • Le traitement doit commencer avant la fin d'un upload — lisez le fichier comme un Stream, morceau par morceau.
  • Le contenu créé par les joueurs a besoin d'un chemin de modération — SubmitUgc, une file, un verdict, des Hooks aux deux bouts.
  • Une image maîtresse doit servir de nombreuses plateformes — dérivez des variantes (redimensionnement, transcodage) et gardez l'original comme référence.
  • Passez votre chemin pour de petites charges utiles structurées — un champ d'enregistrement Data les porte sans session d'upload.

Qui fait quoi

ActorSur cette page
playertéléverse des morceaux, lit des fichiers en flux, soumet du UGC
moderatorexamine la file, approuve ou rejette les soumissions
backend-servicedérive les variantes d'asset ; accroche l'upload et la modération ; fixe les quotas

En un coup d'œil

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

Le UGC, chemin du joueur :

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

Les barrières autour sont des Hooks, même contrat que partout :

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Les deux Hooks enveloppent une étape d'opération, jamais un Event : Before(Files.Upload) décide si l'upload démarre, After(Files.SubmitUgc) s'exécute une fois la soumission existante et la met dans la file de modération par le Moderation.Enqueue propre au module. Les Events — upload achevé, variante prête, UGC soumis, verdict de modération — vont aux abonnés, et un abonné n'oppose de veto à rien.

Le modèle

Un fichier, ce sont des octets opaques plus des métadonnées déclarées — origine, type de contenu, taille — et ce n'est pas un magasin d'état sur lequel des décisions sont prises. Son lien au modèle de jeu va dans l'autre sens : un champ sur votre type tient la référence ; le fichier ne connaît pas le jeu.

Ce qu'un genre de fichier déclare.

DéclareCe que c'est
origincontenu écrit, généré par le jeu, ou généré par l'utilisateur — et les limites de taille et les politiques en découlent
admissible content typescomme une liste déclarée, jamais devinée à partir des octets
size limitsvérifiées à l'ouverture de la session, d'après la taille déclarée, plutôt que sur le dernier morceau
part size and orderl'upload est effectué par une session : une taille de morceau déclarée, l'ordre des morceaux, un point de reprise
derivativesoptionnellement, des variantes nommées produites par un gestionnaire — et la disponibilité de chaque variante déclarée est observable, si bien qu'un client ne devine jamais si la vignette existe déjà
storage prefixsur le champ de schéma qui porte la référence : où vivent les octets, et rien de plus. Pas un répertoire — pas de renommage, pas de déplacement, pas de permissions sur un préfixe, pas d'opération récursive. Le fichier reste adressé par son id ou sa key, et le préfixe n'y prend aucune part

Ce qui vaut pour tout fichier.

ToujoursCe que c'est
completionest idempotente par session : une finalisation répétée renvoie le même fichier plutôt qu'un second
a checksumest obligatoire, et un désaccord est un refus, jamais une acceptation silencieuse d'octets corrompus
a published fileest immuable : une retouche est une nouvelle version, et une référence à une version continue de pointer là où elle pointait
authored contentest adressé par clé plus version, et il est managed : non édité dans la console d'administration, parce que le code le possède
an unfinished sessionmeurt de façon observable : au-delà de son échéance elle est terminée avec un Event et ses morceaux sont libérés
ownershipsuit le prédicat de propriétaire : les fichiers générés par l'utilisateur et par le jeu ont un propriétaire comme toute ligne possédée, et les fichiers d'un propriétaire obéissent à la politique de suppression du joueur — cascade, refus ou anonymisation, déclarée plutôt que supposée
read accesspeut dépendre d'un entitlement : un asset payant est filtré par l'entitlement de Commerce plutôt que par un second système de permissions

Chaque point d'extension nomme le type qu'il remet au Hook — l'intention d'upload avant l'upload, la soumission après — si bien qu'un Hook ne reçoit jamais de sac non typé.

Erreurs

  • Pas d'entitlement répond not found, pas forbidden — sinon la liste des refus révèle quels contenus additionnels existent. Un fichier retiré répond de la même façon.
  • Une session expirée est un conflit : ouvrez-en une nouvelle.
  • Un morceau hors de l'ordre ou de la taille déclarés, un type de contenu non déclaré, et une taille au-delà de la limite sont des refus de validation — et celui de la taille tombe à l'ouverture de la session, pas après que les octets ont voyagé.
  • Un désaccord de somme de contrôle est un refus de validation qui vaut la peine d'être répété : renvoyez le morceau.
  • Le quota épuisé est un conflit, retentable après avoir libéré de la place.
  • Une habilitation de lecture expirée répond not authenticated — demandez-en une nouvelle plutôt que de traiter cela comme un problème de permission.
  • Le rejet par l'examen est un verdict, pas un refus : la soumission a été examinée et la réponse est négative avec une raison déclarée, si bien que la suite dépend de la raison.
  • La cadence d'upload dépassée répond dans la catégorie limite de débit, avec une échéance.

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • La taille d'un fichier selon l'origine — l'upload est refusé avant qu'aucun morceau ne soit accepté, pas au dernier.
  • La taille de morceau — le morceau est refusé comme échec de validation.
  • La durée de vie de la session — expired avec un Event, et les morceaux sont libérés.
  • Le quota de stockage par Project et par joueur — une nouvelle session est refusée comme conflit, et ce qui est déjà publié n'est jamais supprimé en silence pour faire de la place.
  • Versions de contenu écrit conservées — la plus ancienne est retirée, et une version référencée par un Environment en vigueur ne l'est jamais.
  • La cadence d'upload par Actor — une limite de débit avec une échéance.
  • La rétention du contenu généré par le jeu — au-delà de la période, un retrait avec un Event.

Parcours utilisateur

Un niveau construit par un joueur, du premier morceau téléversé jusqu'au verdict d'approbation.

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Analytics

Tout ce qui doit être compté plus tard plutôt que vu maintenant. Déclarez un Event de télémétrie typé, émettez-le, et il atterrit à côté de ceux de la plateforme — un niveau terminé, une étape d'entonnoir, un événement économique, la durée d'une session, un abandon dans le tutoriel. Ce module émet ; il ne lit pas, n'agrège pas, et n'expédie lui-même rien nulle part — la direction que prend un lot appartient au routeur, dans Extensibility.

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

Quand l'utiliser

  • Quelque chose doit être compté plus tard — une étape d'entonnoir, un niveau terminé, un événement économique, la durée d'une session.
  • La comparaison doit survivre aux builds du jeu — un type porte une version de schéma, si bien qu'un entonnoir vieux d'un an n'est pas silencieusement un collage de deux sens différents d'un même champ.
  • Le volume est élevé et une ligne perdue est acceptable si vous l'avez dit — la télémétrie est le seul endroit du contrat où une perte déclarée est licite.
  • Passez votre chemin quand quelqu'un doit réagir — un Event de télémétrie n'a aucun abonné ; un fait que d'autres doivent entendre est un Event de jeu.

Qui fait quoi

ActorPeutNe peut pas
any actordéclarer des types dans le schéma ; émettre pour son propre compte, un à un ou en lot ; lire les types déclarésremplir le contexte ; lire, interroger ou agréger ce qui a été émis
backend-servicela même chose, et émettre pour le compte d'un joueur par délégationlire la télémétrie — il n'y a pas de permission de lecture, parce qu'il n'y a pas d'opération de lecture

En un coup d'œil

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

La définition est du schéma, si bien que ce qui arrive porte des champs nommés et typés plutôt qu'un blob JSON — et elle est déclarée dans sa propre forme porteuse, non comme un Event de jeu avec un drapeau, si bien que l'on peut dire lequel des deux c'est d'après la Declaration sans rien exécuter.

Le modèle

Ce qu'un type d'Event de télémétrie déclare.

DéclareCe que c'est
namecelui du type
fieldstypés par le système de types de la plateforme ; un masque de champs s'y applique comme partout ailleurs
schema versionobligatoire, et non dérivée de la version du SDK — un entonnoir compare des Events collectés sous des builds de jeu différents, et sans version la comparaison mélange silencieusement l'incomparable
samplingquelle part des Events de ce type passe. Déclarée sur le type, jamais choisie par l'implémentation selon la charge : une part qui change d'elle-même rend les entonnoirs incomparables d'un jour à l'autre, et cela ne se remarque qu'après que des décisions ont été prises dessus
loss tolerancesi ce type tolère la perte. La télémétrie est le seul endroit du contrat où une perte déclarée est licite
deletion behaviourcomment la suppression d'un joueur atteint ce type — par suppression ou par anonymisation. Le studio déclare la politique ; le module l'exécute

Ce qui vaut pour tout Event de télémétrie.

ToujoursCe que c'est
no addressing targetpas de destinataire, pas de Group, pas d'abonnement. Vouloir en adresser un est le signe que c'est un Event de jeu qu'il faut
contextest celui de la plateforme : elle ajoute l'Actor ou un marqueur d'anonymat, la session, l'Environment, la version du build, et l'instant selon l'horloge déclarée. L'appelant ne peut pas le remplir : un appelant qui substitue l'Actor ou la version du build reçoit les valeurs dérivées au lieu de celles passées
the sampling sharevoyage avec l'Event : sans elle, le nombre absolu ne peut pas être reconstruit à partir de ce qui est arrivé
lossest observable en agrégat : la part non livrée sur une période est disponible au consommateur ; l'observabilité par élément n'est pas promise, parce qu'aux volumes de la télémétrie un message par perte deviendrait lui-même un flux
emissionn'est pas idempotente : deux appels sont deux faits, et elle n'accepte aucune clé d'idempotence — supprimer le second perdrait de la donnée. Il n'y a aucun moyen d'établir l'issue d'une émission perdue, et on n'en veut aucun : la tolérance à la perte est déclarée à l'avance, pour tous les appels d'un coup
the eventssont l'historique : le module n'en garde aucun à lui

Erreurs

  • Un type non déclaré, et un champ qui ne correspond pas au schéma du type, sont des refus de validation — ni l'un ni l'autre n'est une surprise à l'exécution, parce qu'un type atteint la console d'administration depuis sa Declaration.
  • Un Event surdimensionné est un refus de validation ; les champs ne sont jamais bornés en silence.
  • La cadence dépassée répond dans la catégorie limite de débit, en portant le délai avant lequel un réessai est inutile.
  • Un rejet par échantillonnage n'est pas une erreur, et une perte admissible non plus. L'appel a été exécuté et le rejet est un comportement déclaré ; signaler l'un ou l'autre comme un échec rendrait le comportement déclaré indiscernable d'une faute.
  • Un lot est traité en entier ou par élément, et lequel des deux est déclaré — jamais « ce qui s'est trouvé arriver ».

Limites

Chaque plafond nomme son comportement au bord ; les nombres derrière eux arrivent avec le chapitre des limites de la plateforme.

  • Cadence d'Events par Actor — un refus de limite de débit avec un délai. Dépasser la cadence n'entraîne jamais de perte silencieuse : soit ce refus, soit un rejet par échantillonnage déclaré, et il n'y a pas de troisième issue.
  • Taille d'un Event — un refus de validation, jamais des champs bornés en silence.
  • Taille d'un lot — rejeté avant l'envoi plutôt qu'appliqué partiellement.
  • Types déclarés par Project — une nouvelle Declaration est rejetée au déploiement, pas à l'exécution.
  • Champs dans un type — de même, au déploiement.
  • Période de rétention — une fois expirée, l'Event est indisponible selon la période déclarée.

Parcours utilisateur

Le coup fatal devient un Event de télémétrie typé, échantillonné par sa Declaration et compté plus tard. L'ability et le Stat qui le portent sont des entity presets, pas des modules.

Beyond the SDK

Le plan opérateur

Ce qui n'est délibérément pas dans le SDK. Cycle de vie des Projects et des Environments, déploiement et retour arrière, facturation, administration de l'organisation et des utilisateurs, routage de grappe — tout cela appartient au panneau d'administration, à la CLI et à la surface MCP, pas au code du jeu. La seule exception délibérée est Schema as Code : le schéma est une surface de développeur, il est donc dans le SDK.

Un modèle, deux plans

Le plan SDKLe plan opérateur
Atteint depuisle code du jeule Control Panel, la CLI, MCP
TientRooms · Entities · joueurs · commerce · LeaderboardsProjects & Environments · déploiement et retour arrière · facturation · administration de l'organisation et des utilisateurs · routage de grappe
Travaille enDeclarations, Hooks, Events, Operationsles écrans propres au panneau

Ils partagent un modèle : la Declaration que vous poussez est celle que le panneau rend. Ce qui ne franchit pas la ligne, c'est l'autorité — le code du jeu ne peut ni déployer, ni facturer, ni déplacer un locataire.

Chaque surface du SDK — chaque module, et les presets déclarés sur les Entities — a une contrepartie opérateur dans le Control Panel, où les mêmes Declarations sont vues et éditées depuis l'autre côté :

Surface du SDKCe que l'opérateur voit
Schema / DataEntities, migrations, navigateur d'enregistrements, vues sauvegardées, import/export
Entitymachines à états, inspection par instance
Entity Presetstables de drop, définitions d'ability, de Stat et de projectile, presets d'objets de monde — réglables en direct
Accessla grille des rôles : rôles × opérations, filtres de lignes, masques de colonnes
Extensibilitychaînes de scénarios avec redéfinitions, ordre résolu, traces d'invocation
Roomsla flotte : Rooms, santé du Tick, placement, état de vidage
Matchmakingfiles, tickets en vol, courbes de relâchement
Commercecatalogue, planification des boutiques, reçus, remboursements
Leaderboardscycles, correction d'enregistrements (auditée), cadences de soumission
Authfournisseurs, sessions, bannissements, le scénario d'authentification
Filesassets, files d'examen UGC, quotas
Analyticstableaux de bord, redirecteurs, retard d'ingestion
Mapcartes et obstacle sets, instances vivantes
Visibility / Collision / Locomotion / Predictionréglage par Room : règles, paires de réponses, fenêtres, coût de paquet par Actor
Groups / Messagingnavigateur de Groups, gabarits, filtres de modération, plannings
Botsprofils, quotas de remplissage, points d'accès des cerveaux
Inventory / Profiledétentions et transferts, vues et ensembles d'Entities possédées

La règle de conception. Une capacité du SDK sans surface dans le panneau est invisible pour le live ops ; une surface de panneau sans capacité dans le SDK est un mensonge. Les modules livrent les deux moitiés ensemble, et une Declaration écrite dans l'un ou l'autre endroit est le même modèle dans les deux.

Le voyage d'une Declaration : le schema-author l'écrit, playserv push la porte, le panel la rend pour l'operator, et le réajustement atterrit dans des Rooms déjà en train de tourner.

Accès par agent

Tout ce que le panneau montre est aussi atteignable par outillage : la plateforme expose une surface MCP (la même API qu'emploie le panneau), si bien que des agents IA et des scripts opèrent des Projects (bootstrap, schéma, enregistrements, joueurs, déploiements) sous le même modèle d'accès que n'importe quel autre Actor.

Beyond the SDK

Sous le capot : transport et hub

Une référence d'architecture, pas une surface que vous appelez. Rien de ce qui est sur cette page n'apparaît dans l'API contre laquelle vous écrivez : il n'y a pas de socket à ouvrir, pas de Channel à choisir, pas d'enveloppe à remplir, pas de réessai à planifier. Le code de votre jeu ne rencontre jamais la machinerie de cette page — c'est là tout l'enjeu. Notions essentielles nomme la pile ; le mécanisme ne vit qu'ici. Il est ici pour qu'un architecte puisse vérifier ce que le SDK fait d'une connexion tombée, d'un module désactivé ou d'un message qui doit arriver exactement une fois.

La pile de couches

The runtime stack from your code down to the transports, with the line below which you never call anything

Cinq couches, de haut en bas : l'espace utilisateur, les modules, les Primitives, le hub, et les adaptateurs de transport en dessous. Les deux du haut sont l'espace utilisateur ; tout ce qui est en dessous est l'affaire propre du SDK.

  • L'espace utilisateur est votre code. Il voit des modules, et le vocabulaire s'arrête là.
  • Les modules sont la couche appliquée : Rooms, Matchmaking, Inventory, Leaderboards. Ils forment un graphe, pas un arbre, ce que le hub doit résoudre quand l'un d'eux est désactivé (Inheritance & Composition est la forme elle-même).
  • Les Primitives sont la première implémentation que tout référence : data, Events, RPC, Groups. Un module est un assemblage nommé de Primitives plus ses propres règles.
  • Le hub est le contrôleur : injection de dépendances, montage des modules, session utilisateur, récupération d'état, qualité de service des messages, et routage de chaque message entrant vers le module monté pour lui.
  • Les transports sont des adaptateurs vers un protocole. Il en existe plusieurs ; le hub les traite de la même façon.

Les transports sont des adaptateurs

Il y aura plus d'un transport, et ils diffèrent de façons qui fuiteraient sinon dans chaque module :

AxeÉtendue
Formepiloté par messages ou par requêtes
Canauxmonocanal ou multicanal
Étatavec récupération de l'état de connexion, ou sans
ProtocoleTCP ou UDP

Aujourd'hui cela veut dire WebSocket, le transport UDP de PlayServ, et du HTTP ordinaire. Chacun est un adaptateur derrière ses propres détails d'implémentation, et chacun expose la même chose vers le haut : une session de transport. Le hub tient une session, jamais un socket, si bien que rien au-dessus de l'adaptateur ne raisonne sur l'interface réseau.

Une frontière déclarée. Un canal de transport et une session de transport à la fois. Faire tourner un transport backend pour les Leaderboards pendant qu'un transport master-client porte la session en direct est hors périmètre, et l'API ne le promet pas — aucune signature n'a de sens seulement avec plusieurs canaux ouverts. Qu'une version ultérieure l'ouvre se règle avec la surface invariante par Project ; d'ici là, la forme à session unique est le contrat.

Le hub cache complètement le transport

Vers le bas, le hub parle l'interface de transport. Vers le haut, il offre l'état, les Events et la session utilisateur. Le code de module et le code de jeu sont également incapables de dire quel transport se trouve en dessous, ni comment le hub a regroupé un appel, ni ce qu'il a fait pour revenir à un état cohérent après un trou.

  • La session utilisateur appartient au hub, pas à un module. Reconnexion, reprise et récupération d'état ont lieu une fois, au hub, pour tout ce qui y est monté.
  • La QoS des messages appartient au hub, pas au module data. Enveloppes, réessais et empaquetage sont de la mécanique de hub.

QoS des messages — exactement trois niveaux

Un module ne déclare que la garantie de livraison dont il a besoin :

NiveauSens
at least oncere-livré jusqu'à accusé de réception ; le récepteur tolère les doublons
at most onceenvoyé une fois, jamais retenté ; la perte est acceptable
exactly oncedédupliqué et accusé ; le coûteux, employé là où il est exigé

Cette Declaration est toute la conversation sur la livraison. Comment la garantie est tenue n'est pas l'affaire du module, et ce n'est pas la vôtre.

Injection de dépendances, montage, et modules désactivés

Le hub instancie les modules et les monte — à la racine ou dans un espace de noms — en résolvant les dépendances de chaque module envers les Primitives et envers d'autres modules. Un build qui n'a pas besoin d'un module ne le monte pas. Le montage est cloisonné par espace de noms, et un second module qui revendique un point de montage déjà occupé est rejeté au moment du montage — la composition échoue là, jamais au premier appel qui y entre.

Parce que les modules forment un graphe, en couper un a des conséquences en aval, et le hub prend exactement l'un de deux chemins :

  1. Désactiver la chaîne dépendante. Chaque module qui a besoin du module manquant est coupé lui aussi, et ses interfaces sont absentes plutôt que défaillantes.
  2. Déclarer un fonctionnement dégradé. Les dépendants restent montés et annoncent ce qu'ils ne peuvent plus faire.

Il n'y a pas de troisième chemin. Le demi-fonctionnement silencieux — un module monté abandonnant discrètement les opérations qu'il ne peut plus effectuer — est le mode de défaillance que cette règle existe pour empêcher, et c'est pourquoi une dépendance désactivée est observable plutôt que mystérieuse.

Pourquoi vous ne rencontrerez rien de tout cela

Chaque promesse des pages de module est tenue au-dessus de cette ligne : muter une Entity est l'opération réseau, un Hook est une fonction typée, une entrée tient en un appel. Les noms de la pile peuvent vous parvenir — Notions essentielles pointe ici — mais la promesse est que vous n'appelez jamais rien de tout cela, pas que les mots soient secrets. Les couches du dessous existent pour que ces promesses survivent à un changement de transport, et une page que vous n'avez jamais à lire est la mesure de ce bon fonctionnement.

Ce que vous rencontrerez — le contexte de livraison sur lequel s'exécutent vos gestionnaires, le moment où un handle prend fin, et l'implémentation en mémoire contre laquelle vous testez — est une page plus haut : Threads, durée de vie et tests.

Start here

PlayServ SDK

O backend do jogo que vem junto com a jogabilidade. PlayServ é um backend-as-a-service para jogos ao vivo: um estúdio opera o backend do seu jogo — dados, jogadores, Rooms, Matchmaking, comércio — sem hospedá-lo. O SDK é como o seu código, no servidor e na engine, trabalha com essa plataforma.

Esta página é a lista curta do que aqui é realmente diferente. Tudo abaixo se decide uma vez por projeto e se configura, em vez de se escrever; o que você chama mora nas páginas de módulo, e cada seção daqui termina apontando para o dono.

One declaration you write, and the five things that happen with no further code from you

A simulação não é o seu código

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Rooms, Collision, Locomotion, Prediction e a sincronização rodam dentro da plataforma. O seu jogo são Declarations (Entities, mapas, habilidades, tabelas de drop, política de sincronização), Hooks (as suas regras, chamadas em passos que têm nome), Events (assine, não fique consultando) e Operations (o que você pede ou comanda). Cada página de módulo é organizada exatamente em torno desses quatro.

O que você não escreve é bem concreto — o laço do jogo, a montagem de snapshots, o codificador de Delta, a resolução de colisões, a integração do movimento, o tratamento de reconexões, a validação de acertos com compensação de lag. Veja Getting Started, onde exatamente isso é construído.

Mudar estado declarado é a chamada de rede

Não existe envio. Você declara como um campo sincroniza — um atributo ao lado do campo — e mudá-lo já é a operação de rede: Delta contra o último estado confirmado, o aspecto como unidade de política, prioridade e taxa de envio, a janela retida, Hooks antes e depois da mudança. Nada abaixo disso é escrito por você.

O mesmo movimento vale para todo o resto que se declara: um Event, um RPC, um Group, um eixo de Leaderboard. A Declaration é a entrada da API tipada, do painel administrativo que a desenha e da geração de código para todos os bindings — e é por isso que o que se versiona é a Declaration, não o código gerado. Veja Data & Subscriptions e Schema as Code.

As interfaces seguem o Actor, não o lado

Não existe SDK de cliente nem SDK de servidor. Existe um SDK, e o que uma chamada pode fazer é decidido pelo Actor por trás dela — um jogador, um serviço, o cérebro de um bot, um operador.

O caso para o qual isto foi feito é a máquina de um jogador que cria uma Room e depois a conduz: um master-client, que segura as interfaces de room-owner e nada mais. Um build room-visitor não tem kick nem close — não desativados, ausentes.

Os direitos se compõem de permissões atômicas, então não há camadas de papéis embutidas, e um papel limita os dados até a linha e a coluna. Veja Autoridade e Access & Roles — onde a concessão de um direito é escrita.

Um desenho, estreitado duas vezes — sobre uma pilha

Como é escrito

O SDK é um desenho com duas saídas de estreitamento, e a ordem é a regra: nada desce um nível enquanto o nível acima genuinamente ainda puder carregar. Os princípios comuns são idênticos em todos os bindings. A forma da linguagem leva só o que o paradigma dela não consegue exprimir do jeito comum: C# tem atributos, Python tem decoradores — a mesma Declaration, escrita como cada linguagem já escreve essa ideia. A forma da engine leva só o que a engine remodela sobre a sua linguagem: em Unreal a Declaration viaja dentro da macro de reflexão da própria engine, e o C# da Unity também não é o C# do servidor.

Como roda

O seu código de jogo endereça módulos e mais nada. Os módulos são montados a partir de quatro Primitives — Events, RPC, Data & Subscriptions, Groups. Debaixo deles fica o hub que você nunca chama: injeção de dependências, montagem de módulos, a sessão, a recuperação de estado, a qualidade de entrega das mensagens. Mais abaixo, os adaptadores de transporte, um por protocolo, e qual deles carrega uma chamada não é algo que o seu código decida ou perceba.

As duas metades por inteiro: Como o SDK é construído, terminando em Por baixo do capô.

Os módulos se compõem; nada herda

Não há um módulo base do qual derivar nem hierarquia para estender — os módulos formam um grafo, porque uma árvore só admite ramos e as funcionalidades de verdade os atravessam: Matchmaking reserva vagas em Rooms, o drop coloca itens através do Map, um chat mora dentro de uma Room.

"Herança" cobre aqui quatro mecanismos diferentes, e vale a pena distingui-los: Os RPC de uma Entity são parte da Entity e não existem em nenhum outro lugar. Um preset é um conjunto nomeado de aspectos, não uma classe base. Sobrescrever um passo da plataforma é um atributo na sua substituição. Um módulo toma outro emprestado por um decorador que estreita a interface tomada. Veja Herança e composição.

Quem vê o quê é declarado, não filtrado no cliente

Com quarenta jogadores, um snapshot da Room inteira está de bom tamanho; com duzentos, não — e o remédio não é um cano mais grosso. Regras de interesse decidem quem recebe qual fatia, e pacotes por Actor e broadcast são dois modos de entrega de um mesmo modelo declarado: passar de um para o outro é configuração, não reescrita. Coisas distantes degradam por níveis de detalhe declarados antes de desaparecer.

A parte que é propriedade de segurança e não de banda: o estado que não pode vazar não é enviado. Névoa de guerra e campos visíveis só ao dono estão ausentes do pacote, não escondidos no cliente. Espectadores, admins e replays recebem uma visão mais ampla por segurarem um direito mais amplo — o que é de novo autoridade, não um caso especial. Veja Visibility.

O que sobrevive à perda de um host

Um host morrer não encerra a partida. O estado da Room não é copiado entre hosts enquanto se joga — um dono único é o que mantém a ordenação fora do consenso — e o que torna a partida sobrevivível é que o estado está declarado, e estado declarado fica guardado fora do host. Ele é fotografado num intervalo declarado, e um substituto retoma do último instantâneo.

Então o substituto tem o estado inteiro — mas na hora daquele instantâneo. Completo, não atual. O que isso custa é o jogo desde o último instantâneo; o que não está coberto é tudo o que você guardou só em atores do motor. Um deploy usa o mesmo mecanismo, sem a perda: drenar um host é o caminho de failover executado de propósito. Veja What Survives Losing a Host, e Rooms para a janela de carência pela qual um jogador volta a entrar.

Qualquer passo da plataforma pode virar seu

Todo cenário da plataforma é uma cadeia de funções registradas, e você substitui um elo ou o envolve. Sign-in, validação de entrada, compra, envio, upload — cada um é um passo com nome, e a sua substituição é declarada por um atributo, com escolha de versão por condição e o passo da própria plataforma como reserva.

É isso que "plataforma customizável" significa concretamente, e é isso que está no lugar de entregar o nosso código-fonte: você substitui passos, em vez de bifurcar quem os executa. Veja Extensibility.

Uma superfície, seis linguagens

Um contrato, seis projeções: C#, TypeScript, Python e Go no servidor; Unreal C++ e Unity C# gerados na engine. Todo exemplo de código deste site mostra os seis, e onde um binding não tem superfície para um passo a aba nomeia a razão em vez de fingir: o passo roda fora da engine, ou o direito a essa chamada pertence a outro Actor.

Duas consequências que vale conhecer antes de escolher a linguagem: RPC aceita objetos do SDK por referência, e não como DTOs achatados; e os primitivos assíncronos aqui são a base, não um enxerto — Channel, Stream e endereçamento por grupo, de modo que dá para falar com um Group inteiro e juntar as respostas, ou consumir um arquivo em pedaços enquanto ele ainda está subindo.

Há também um host determinístico em memória que executa o seu código de jogo sem backend algum atrás e com o tempo sob o seu controle, de modo que um teste é um teste e não uma corrida; veja Threads, tempo de vida e testes.


O que deliberadamente não está no SDK — deploys, faturamento, administração de organização e de usuários — mora no plano do operador. A barra lateral é o mapa de todo o resto; Getting Started é o caminho mais curto para dentro.

Start here

Começando

Uma arena jogável (mapa, tanques, tiro, drops), declarada de ponta a ponta. Nada do que vem abaixo é um laço de jogo: a simulação roda dentro da plataforma, e este é todo o código que existe.

Antes de começar. Um projeto com ambiente dev (criado no plano do operador, que é dono desse ciclo de vida), o CLI playserv autenticado nele e o pacote do SDK para o seu binding — nada mais é instalado no seu jogo.

Fluxo do usuário

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

Toda chamada que você faz é um dos exemplos abaixo; os passos entre elas são a plataforma agindo conforme o que uma Declaration disse. A Ability, o Stat e a DropTable da figura são Entity Presets — Declarations sobre Entities, não módulos próprios.

1. Declarar o mundo

Entities são o seu schema mais os aspectos vivos delas. Um atributo por comportamento, ao lado do campo que ele descreve:

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

Mudar um campo [Sync] é a operação de rede. Não há snapshot para montar nem chamada de envio para fazer.

Nenhum desses tipos é seu para definir, e cada um pertence a uma página:

No blocoVem de
Vector3, Stato pacote core do seu binding
Body e as formas de corpoCollision
Motion e os cinco modelos de movimentoLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectos Entity Presets que os usam
EntryRequest, Verdict, StatEventpayloads de Hooks, entregues pelo módulo em que você engancha
SeatMatchmaking
Scope, os escopos de sincronizaçãoVisibility
Tick, as taxas de TickRooms

Os enums são fechados. Uma regra que nenhum membro cobre é escrita como predicado, não como membro novo: [Aspect("loadout", Visible = "owner == caller.player")] é como se exprime visibilidade por campo quando Scope.Owner não é exatamente a regra que você queria (Data & Subscriptions).

2. Declarar a Room

Um template de Room diz o que é uma sessão e nomeia as Declarations em que se apoia. Não há classe de Room para herdar nem método de Tick para preencher, porque o interior da Room é da plataforma:

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

Os ids entre aspas são chaves de conteúdo, não texto livre:

ChaveO que nomeia
rockum prop no conjunto de obstáculos do mapa (Map)
ammo.shell, railgunitens de catálogo (Catalog & Commerce) — que é também como o tiro debita munição e o item apanhado cai na mochila
battle, arena, crate-loot, shellas chaves que estas quatro Declarations registram

playserv push recusa uma Declaration cuja chave não exista no ambiente de destino, então uma chave digitada errado falha no deploy em vez de falhar no primeiro cast. Onde quer que o template esteja escrito, ele continua reajustável sem redeploy da engine: o modelo enviado é o que o live-ops edita no painel.

3. Escrever as suas regras como Hooks

Hooks são funções de nuvem que a plataforma chama em passos com nome. Tipado na entrada, tipado na saída — sem sacolas de contexto, sem loggers na assinatura:

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Um portão Before pode recusar o passo; um observador After roda depois que ele já foi efetivado e não pode. Por isso um pacote inicial que não chegou custa 20 projéteis, não o sign-in. Extensibility tem o resto.

Quase nada naquele bloco faz o trabalho que parece fazer:

A linhaQuem realmente a executa
Stats.Depleted disparao Stat chegando ao seu piso — 0 para Hp, já que a Declaration definiu só Max
a transição de morteAtMin = "death" da seção 1; o Hook acrescenta a consequência, não a transição
o dano[Effect(Damage = 35)] no projétil, aplicado pela plataforma no acerto
RollAtgerado sobre a Declaration [DropTable] — por isso ela é partial, e por isso a aba de Go lê drops.RollAtCrateLoot
o apanharpassar por cima do loot transfere os itens para o Inventory do jogador atomicamente; essa transferência é o Event changed que um HUD desenha

Tipos gerados para a engine

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

Rode-o (ou deixe o CI rodar) depois de cada push de schema — os tipos são regerados, nunca editados à mão, e o Tank gerado é o Tank enviado. playserv push lê um projeto de engine exatamente como lê um projeto de servidor: os especificadores do UHT e os atributos de C# são a Declaration, então apontar o CLI para o projeto de UE ou de Unity já é todo o passo de exportação. Em qual thread um callback chega, e quando uma assinatura termina, ficam fixados pelo modelo de runtime — Threads, tempo de vida e testes.

4. Conectar um cliente

A API do cliente é simétrica: os mesmos módulos, e o que um build pode chamar é decidido pela chave sob a qual ele roda. Um build de engine carrega uma chave de jogador — projectKey aqui, a credencial de um projeto e um ambiente, emitida no painel e embarcada no build. Ela não nomeia papéis: os papéis resolvem no servidor a cada requisição, e o jogador por trás deles chega com SignIn. Os bindings de engine são de primeira classe aqui; os bindings de servidor conduzem a mesma superfície em modo headless (o cérebro de um bot, um teste de carga, uma ferramenta de operação):

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

Quatro chamadas, e cada uma responde algo diferente:

ChamadaCom o que responde
Findum ticket colocado, e uma vaga reservada numa Room. A reserva é mantida pelo prazo que o template declara; deixá-la vencer custa a vaga, não o direito de jogar
Joinresolve quando o estado atual da Room chegou. O Tank que o template faz nascer para um membro que entra é parte desse estado, então My<Tank>() responde na linha seguinte e tudo depois de Join é tráfego ao vivo
Castatirar é o verbo da habilidade, não uma segunda superfície: uma Declaration [Projectile] é uma habilidade com balística por cima, então Cast confere recarga, munição e alvo do mesmo jeito que faria para um dash ou uma cura (Entity Presets)
Abilitiesgerado — o codegen junta as habilidades e os projéteis declarados num tipo por binding

As recusas chegam como o Problem tipado da plataforma — um código mais uma razão para gente. C#, TypeScript, Python e Unreal o lançam; Go o devolve como valor de erro, e é por isso que naquela aba toda chamada é conferida. Um servidor dedicado de Unreal ou um master-client roda o mesmo binário sob uma chave de host, e os papéis dele carregam as linhas mc: Rooms → Hosting a room é essa superfície de ponta a ponta, e Access & Roles é onde as chaves e os papéis por trás dela são declarados.

5. Enviar e jogar

playserv push          # schema + declarations + hooks, um deploy
playserv open battle   # uma Room no ambiente dev, viva no painel

playserv push varre o projeto em que roda — Hooks por atributo e Declarations — e implanta no ambiente que você mirar (--env dev por padrão). playserv schema push sozinho move só o modelo, e playserv schema diff é aquilo contra o que um push é comparado (Schema as Code).

Um push chega inteiro ou não chega, e é recusado em vez de mesclado se o schema implantado se moveu desde o seu diff. Uma mudança que quebraria dados existentes não viaja no push: ela vira uma migração que você lê primeiro e depois executa ou cancela (Schema as Code). Mover um modelo de dev para prod é um ato do plano do operador, não uma chamada do SDK (o plano do operador).

playserv open battle cria uma Room a partir do template battle enviado e a abre no painel, onde o estado da Room e os seus membros são inspecionáveis enquanto você joga contra ela. O painel agora mostra o template, o mapa, a DropTable e os Hooks: o mesmo modelo que você escreveu em código, editável ali também.

Os números que você não escolheu

Capacity = 8, Hz30, Hz = 10 e Cooldown = 1.5f são o ajuste deste jogo, não tetos. Os limites da própria plataforma ficam acima deles, e cada um é declarado junto com o que quem chama observa na fronteira:

Na fronteiraO que quem chama recebe
entrar acima da capacidade, ou numa Room fechadaconflict — vale repetir quando uma vaga abrir
criar Room acima do limite por projeto ou por Actorrecusa, e nada já criado é destruído
criar Rooms ou fazer sign-in rápido demaisrecusa por limite de taxa carregando o tempo de espera
payload de Event acima do teto da Roomrecusado antes do envio, nunca truncado
leitura acima do teto de linhas de um papelas linhas do teto, mais o marcador dizendo que foi cortada

Os números em si são por ambiente e chegam com os limites da plataforma; o comportamento na fronteira não espera por eles (Rooms, Access & Roles, Auth & Players).

Para onde ir depois

  1. Examples, a seção logo depois desta: um Leaderboard em Tanks, caixas de vida em Tanks ou a receita do torneio diário para o meta loop — uma funcionalidade real em cada uma, com cada passo ligando à página de módulo dona do que você acabou de usar.
  2. How the SDK works, quando a forma do SDK passa a importar mais do que a próxima funcionalidade: Conceitos essenciais é o dicionário, e quatro artigos respondem quem está chamando (Autoridade), como uma concessão se escreve (Access & Roles), de que o SDK é feito (Como o SDK é construído) e como ele é executado (Threads, tempo de vida e testes).
  3. Depois os módulos. Toda página de módulo tem a mesma anatomia — tese, actors, quando usar, fluxo do usuário, exemplos, modelo — de modo que a segunda se lê mais rápido que a primeira e a quinta leva minutos. Entity e Data & Subscriptions são as duas em que todo o resto se apoia.

Caminhos de leitura por papel

Qualquer que seja o seu papel, leia Autoridade primeiro — um SDK e uma concessão por Actor é o pré-requisito comum — com Conceitos essenciais aberto ao lado.

Você éLeia, nesta ordem
Dev de cliente (Unity · cliente Unreal · TS)Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, depois por funcionalidade: Inventory · Leaderboards · Messaging · Profile
Dev de servidor (C# · TS · Python · Go)Schema as Code → Events → Entity → Extensibility → Access & Roles, depois os módulos cujas Declarations são suas: Rooms · Matchmaking · Leaderboards · Catalog & Commerce
Dev de servidor dedicado UnrealRooms (Hosting a room) → Bots → Locomotion · World Objects → Map → O que sobrevive à perda de um host
Examples

Leaderboard em Tanks

Tanks, a arena de exemplo de Getting Started, não tem Leaderboard. Esta lição, que você pode fazer a qualquer momento depois de Getting Started, acrescenta um Leaderboard semanal de abates em três passos: declarar o Leaderboard, enviar a partir do Hook de morte, lê-lo no cliente. Cada passo liga à página de módulo dona do que você acabou de usar, então a lição ensina apontando, não repetindo.

Passo 1 — declarar o Leaderboard

Um Leaderboard é uma Declaration: qual campo o classifica, como envios repetidos se combinam, quando ele reseta e quem pode enviar. Aggregation.Increment soma cada envio ao total corrente, então um abate é um ponto. Submit.ServerOnly é o padrão e fecha o Leaderboard para clientes — é exatamente isso que faz do passo 2 a única porta de entrada.

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

Envie com playserv push e o Leaderboard aparece no painel, vazio, com o ciclo de segunda-feira já agendado — segunda 00:00 UTC, já que os agendamentos são em UTC. Os eixos que você não definiu ficam com os padrões deles. Veja Leaderboards para a lista completa de eixos: dono, chave de ordenação, campos de exibição, regras de torneio.

Passo 2 — enviar a partir do Hook de morte

Tanks já encerra uma vida pelo limiar de HP declarado no tanque: com HP zero a transição death dispara e a plataforma chama o Hook depois dela. O Hook é uma função de nuvem, tipada na entrada e na saída, então enviar um abate é uma linha dentro dele.

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By é o atacante que o dano carregava, então nenhuma contabilidade rastreia quem atirou em quem. e.Id é o identificador do próprio Event, e passá-lo como chave de idempotência é exatamente o que um Leaderboard Increment precisa: um Event de morte reentregue conta uma vez, não duas. O ponto do Hook, as garantias de ordem e o contrato de veto são Extensibility; o limiar que o dispara é um preset de Entity Presets no tanque; e a linha de pontuação que ele escreve é Data & Subscriptions comum, que você pode consultar.

Passo 3 — ler o Leaderboard no cliente

Duas leituras cobrem a interface inteira: o topo do Leaderboard e a janela em torno do jogador local — cinco linhas acima, cinco abaixo, mais a sua. Ambas voltam como entradas classificadas com abates e nome de exibição, prontas para ligar a uma lista. Uma assinatura mantém o painel atual enquanto a partida corre, e ela entrega apenas a posição do jogador local.

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

O reset de segunda-feira fecha o ciclo em vez de apagá-lo, então a tabela da semana passada continua legível pelo rótulo dela — a mesma chamada Top com o argumento cycle:. Um Hook no fechamento do ciclo para dar recompensa é o quarto passo natural, descrito em Leaderboards.

Para onde ir depois

Examples

Caixas de vida em Tanks

Antes
um tanque que leva dano fica danificado até morrer. Não há volta, então toda luta é uma contagem regressiva e a arena não tem motivo para ser percorrida.
Depois
caixas de vida aparecem pela arena, espaçadas entre si e longe de quem está lutando. Passar por cima de uma cura você. Nada mais no jogo muda — e o código da Room também não, porque não existe.

Esta é a segunda lição de Tanks. São três passos e nenhum módulo novo: uma Declaration para a caixa, uma Declaration para onde as caixas aparecem e um Hook para o que apanhar uma faz. Faça-a depois de Getting Started, em qualquer ordem em relação à lição do Leaderboard.

Passo 1 — declarar a caixa

Uma caixa é uma Entity com dois presets aplicados e um corpo que reporta contato sem parar ninguém. Response.Pass na camada pickups é o que a torna um item apanhável em vez de um obstáculo: o contato é reportado, o movimento passa direto.

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

Duas coisas que você não precisou escrever: onde a caixa é desenhada (o cliente já desenha World Objects declarados) e como a posição dela chega aos clientes — [Sync] é a chamada de rede.

Donos: Entity Presets e Collision.

Passo 2 — declarar onde as caixas aparecem

A colocação também é uma Declaration, e é o passo que decide se a mecânica parece justa. O espaçamento evita que as caixas se amontoem, a distância dos jogadores evita que apareçam no meio de um duelo, e a regra de não repetição evita que o mesmo ponto seja a resposta todas as vezes.

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

As posições válidas vêm de Map — a tabela pede um ponto na camada ground e o Map responde com um que é de fato alcançável, então uma caixa nunca cai dentro de uma parede.

Donos: Entity Presets e Map.

Passo 3 — curar ao apanhar

Um Hook, e é o único código da lição. Ele roda na plataforma como função de nuvem, e é por isso que não aparece em nenhuma das abas de engine.

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

Três coisas que este Hook ganha de graça, e cada uma explica por que a lição é tão curta:

  • A recusa é tipada. Um tanque com vida cheia recebe already at full health com uma razão que o cliente pode mostrar, e a caixa continua ali para quem precisar.
  • Ajustar um Stat é autoritativo no servidor. Não é algo que um cliente possa pedir, então não há exceção de anticheat a escrever para itens apanhados.
  • O HUD se atualiza sem ser avisado. Adjust emite o Event changed; o cliente já está assinando os Stats declarados do tanque. Você não escreveu mensagem de rede alguma.

Donos: Extensibility e Entity Presets.

O que mudou, e o que não

AntesDepois
um tanque danificadofica danificado até morrerpode se recuperar percorrendo a arena
código da Roomnenhumcontinua nenhum
módulos novos montados—nenhum: duas Declarations e um Hook
exceções de anticheat—nenhuma: curar é autoritativo no servidor como toda mudança de Stat

Para onde ir depois

  • Entity Presets — o gerador de drop, os World Objects e o modelo de Stats em que esta lição se apoiou, os três presets de entity e não módulos.
  • Collision — camadas, respostas e a diferença entre um contato reportado e um que bloqueia.
  • Map — como uma posição válida é escolhida, e o que "alcançável" quer dizer.
  • Extensibility — todos os pontos de Hook em ordem, com o contrato de veto.
  • Leaderboard em Tanks — o outro exemplo de Tanks.
Examples

Torneio diário

O que você obtém: um torneio diário com janela de inscrição, Rooms semeadas e pagamento de prêmios — construído inteiramente com Declarations e Hooks sobre módulos que já têm página. Nada aqui é conceito novo; são Leaderboards, Matchmaking, Rooms, Catalog & Commerce e Messaging compostos para um meta loop.

Passo 1 — declarar o Leaderboard com janela de inscrição e limite de tentativas

Um torneio é uma Declaration comum de Leaderboard mais restrições de participação: uma janela de inscrição, um teto de participantes e tentativas por ciclo. Nada muda na pontuação — a chave de ordenação, a agregação e o reset ficam exatamente como em qualquer Leaderboard.

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

Envie e o painel mostra um chaveamento vazio com a janela agendada. joinRequired: true faz dos participantes uma associação em vez de todo mundo que joga, então um envio de quem não é participante é recusado. Veja Leaderboards para o resto da lista de eixos e para o que cada restrição faz na fronteira dela.

Passo 2 — a janela abre: uma party entra, as Rooms são semeadas

Assim que a janela de inscrição abre, os jogadores entram na fila exatamente como em qualquer partida: criar ou entrar numa party, depois uma chamada Find. O Matchmaking coloca a party num chaveamento de torneio e Rooms semeia a partida — o mesmo caminho de colocação e vaga que toda partida usa, só que no escopo da fila do torneio.

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

Os tetos do passo 1 pertencem ao Leaderboard, não ao Matchmaking: a fila coloca parties, e é o Leaderboard que encontra um participante acima do teto ou um jogador além das tentativas. O participante 65 de 64 é recusado como conflito, sem que nada seja descartado, e um quarto envio no mesmo ciclo responde "tentativas esgotadas" — também um conflito, resolvido pelo reset diário e não por pedir uma permissão.

Passo 3 — as pontuações são enviadas pelo Hook de dispose

Rooms não reportam um vencedor a um Leaderboard por conta própria; essa ligação é um Hook, com o mesmo contrato de Extensibility de sempre — tipado na entrada, tipado na saída, sem sacola de contexto. O Hook on dispose da Room (Rooms) é a última coisa que roda com o estado final da partida em mãos, e o envio sai dali.

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner e FinalScore são campos que o próprio template de Room deste jogo declara no estado dele — a plataforma não acrescenta nada ao snapshot (Rooms é onde o estado do template é declarado). O Hook de dispose (Rooms.Disposed) entrega o snapshot final, então a partida nunca é recalculada. O Hook de pré-envio de Leaderboards continua rodando primeiro — uma pontuação de chaveamento está sujeita ao mesmo contrato de corrigir-cortar-ou-recusar que qualquer outro envio.

Passo 4 — o ciclo fecha: recompensas concedidas, jogador notificado

O reset diário do passo 1 fecha o ciclo exatamente como qualquer reset de Leaderboard, e dispara CycleClosed carregando o rótulo do ciclo que fechou — é esse rótulo que faz o Hook ler a tabela que acabou de fechar em vez da vazia que acabou de abrir.

Um Hook faz o resto: concede o prêmio pelo caminho de entitlement de Catalog & Commerce e empurra o resultado por Messaging, então não há tarefa de pagamento separada para rodar. Uma notificação é endereçada a um Actor, então o top 8 é um laço de oito, cada um levando o seu próprio argumento rank para o template.

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

Os números do passo 1 são valores seed: o LiveOps reajusta a janela, o teto de participantes e a contagem de tentativas no painel, e o deploy seguinte não sobrescreve a mudança em silêncio. Tornar isto um torneio semanal é uma edição — Reset.Daily() vira Reset.Weekly(DayOfWeek.Monday), e os passos 2 a 4 ficam como estão.

Para onde ir depois

How the SDK works

Conceitos essenciais

As palavras que o resto destas páginas usa sem parar para explicar. Uma página de módulo supõe que você já sabe o que é um Actor, um aspecto ou uma Room — aqui cada um ganha uma definição de uma linha e um link para a página onde o mecanismo por trás dele de fato mora. Leia uma vez antes da referência de módulos, ou volte quando uma palavra se revelar mais pesada do que você esperava.

Três coisas são grandes demais para um verbete e têm uma página cada: Autoridade — quem está chamando, e o que só isso já decide; Como o SDK é construído — de que o SDK é feito; Herança e composição — como os módulos se constroem uns sobre os outros. Nessa ordem, eles se leem como um só argumento.

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

As quatro superfícies

Todo módulo expõe exatamente quatro coisas, e toda página de módulo é organizada em torno delas. Este é o modelo de programação:

SuperfícieO que significa
Declarationso que existe e como se comporta, escrito em código ou no painel administrativo; o mesmo modelo dos dois jeitos
Hooksas suas regras, chamadas pela plataforma em passos com nome; implantadas como funções de nuvem
Eventso que a plataforma conta que aconteceu — assine, não fique consultando
Operationso que você pede ou comanda, de uma função ou de um cliente

Actor

Quem está fazendo uma chamada. O que uma chamada pode fazer é decidido pelo Actor por trás dela, nunca por dentro de qual build o código foi compilado — o argumento é Autoridade, o mecanismo (permissões atômicas, papéis compostos, InterfaceGrant) é Access & Roles.

Esta documentação tira os nomes de Actor de um catálogo — os presets que a plataforma entrega. É um conjunto de presets, não uma lista fechada (um projeto nomeia os Actors dele), mas toda linha actors de um esquema, toda linha de "quem faz o quê" e toda ficha de diagrama nestas páginas usa exatamente estas grafias:

player · backend-service · operator · host · moderator · schema-author · architect · bot-brain · room-owner · room-visitor · entry-validator · spectator · match-organizer · warehouse-keeper · seller — e any quando a página quer dizer todos eles.

Uma página pode ainda introduzir um papel de cena para um diagrama — um participante descritivo como member ou attacker — desde que a prosa dela ou a tabela de quem-faz-o-quê o apresente antes.

Runtime surface

Onde o código roda. Estas quatro etiquetas aparecem em toda tabela de Operations:

EtiquetaSuperfície
fnFunção de nuvem (C# · TypeScript · Python · Go). Autoritativa no servidor; a casa principal das suas regras
clCliente de jogo (Unreal C++ / Unity C#). API simétrica; os papéis destravam menos
mcA superfície do host da Room: um master-client (um cliente que é dono de uma Room) ou um servidor dedicado Unreal sob a chave de host dele
admPainel administrativo / CLI / MCP — onde o SDK e o plano do operador compartilham um modelo

Este é o eixo que constantemente se confunde com o de cima. Onde o código roda e qual interface de Actor ele segura são duas perguntas separadas: o mesmo código segura os mesmos direitos onde quer que esteja, e o que muda é só a concessão.

Project & Environment

Um Project é o backend de um jogo, com um schema e os dados dele em Environments isolados (dev, prod). Toda chamada roda dentro de um par Project + Environment.

Entity

O substantivo central. Uma Entity é uma declaração de schema mais os aspectos vivos dela: dados 0..*, estados 0..*, RPC 0..*, Events 0..*, Hooks e histórico de mudanças. Um tanque, uma porta, uma barra de Stat e uma missão são todas Entities, diferindo apenas em quais aspectos carregam. As combinações comuns vêm como presets (GameObject, Stat, Character, Interactable, Projectile). Veja Entity.

Expected state

Um pedido de transição pode nomear o estado que espera, e então é esse estado ou uma recusa. Um pedido que não nomeia nenhum é avaliado contra o estado que a máquina tem quando a plataforma o processa — nunca contra o estado no momento em que foi enviado.

A resposta descreve esse momento e não promete nada sobre o depois: a transição de outra pessoa chegando enquanto a resposta está em trânsito deixa a resposta verdadeira e não a cancela. Então nomeie o estado esperado quando o desfecho depende do que havia antes, e caso contrário não leia a resposta como um snapshot que sobrevive à chamada.

Room

Uma Room é uma sessão de jogo, não um lugar onde o seu código roda. A plataforma não se importa com o que a hospeda: um servidor dedicado, um master-client ou o próprio backend. O interior da Room é nosso; você conduz uma Room de fora, a partir de funções de nuvem e de clientes, por Declarations, Hooks, Events e Operations. Veja Rooms.

Channel & Stream

Os primitivos assíncronos debaixo de tudo. Um Channel é um tópico pub/sub endereçável: uma Room, um Group, uma Entity ou o seu próprio. Um Stream é um fluxo em pedaços em qualquer direção: arquivos são consumidos conforme os pedaços chegam, consultas podem transmitir, e um RPC pode se espalhar por um Group e recolher as respostas. Veja Core.

Primitive

Um dos quatro tijolos com que todo módulo é montado: Events (declarar, emitir, assinar), RPC (invocar através do fio), Data & Subscriptions (a mecânica de sincronização) e Groups (uma lista, muitos ouvintes). Uma Room, um chat e uma fila de Matchmaking são o mesmo Primitive group sob regras diferentes. Se uma funcionalidade não pode ser exprimida pelos quatro, isso é um defeito de desenho e não um argumento por um quinto.

Hook contract

Um contrato em toda parte: um Hook before roda antes da validação, recebe o payload tipado, pode alterá-lo ou rejeitar; um Hook after roda depois que a operação foi efetivada, recebe pedido e resultado, e só pode acrescentar efeitos colaterais — nunca pode fazer a operação falhar. Os Hooks são ordenados, e todo passo registrado da plataforma pode carregá-los. Veja Extensibility.

Delta & Revision

Os clientes recebem estado como Deltas: apenas os campos que mudaram, codificados contra o último estado que o receptor confirmou. Todo registro carrega uma Revision; escritas condicionais recusam em caso de divergência. Um conceito de versionamento serve à sincronização, à concorrência e ao histórico. Veja Data & Subscriptions. (O que Unreal chama de replicação — qual cliente vê qual estado, com que frequência — mora aqui, e também em Visibility e Prediction & Lag Comp. A página O que sobrevive à perda de um host é a outra: qual máquina é dona de uma Entity, e qual será a próxima.)

Tick

Rooms simulam em passo fixo. Toda mudança de estado é carimbada com o Tick dela; sincronização, Prediction, compensação de lag e histórico contam em Ticks, e não em relógio de parede. Os dados carregam o tempo real do evento — é isso que torna a rebobinagem e a reconciliação exatas. Veja Prediction & Lag Comp.

How the SDK works

Autoridade

Autoridade é uma abstração, não dois builds do SDK. Não existe SDK de cliente nem SDK de servidor. Existe um SDK, e o que uma dada chamada pode fazer é decidido pelo Actor que a faz.

Um master-client não é nem cliente nem servidor

A máquina de um jogador que cria uma Room e depois a conduz — um master-client — segura as interfaces de room-owner e nada mais. Servidor ela não é: não pode tudo o que um servidor pode. Cliente comum também não é.

Um servidor dedicado é a mesma figura pelo outro lado: o mesmo cliente sem a renderização, e não precisa de um SDK separado. O que separa os dois é confiança, não construção, e a confiança é carregada pela concessão.

As interfaces seguem o Actor, não o lado

Um módulo não expõe "a API do cliente" e "a API do servidor". Ele expõe o que um room-owner pode fazer, o que um entry-validator pode fazer, o que um seller pode fazer. Cliente e servidor são encanamento; Actors são o domínio. Dentro de cada página de módulo a superfície é agrupada do mesmo jeito — isto é para estas necessidades, aquilo é para aquelas.

Um papel é o direito e a classificação, os dois

Aqui há exatamente uma dimensão. Um papel carrega o que um Actor pode fazer, e é também como se diz a quem algo é endereçado. Deliberadamente não acrescentamos um segundo eixo de etiquetas ao lado dele: uma coisa a declarar, uma coisa a verificar, uma coisa a ler no painel administrativo.

whoami é como o código pergunta. Ele reporta o Actor e as interfaces que esse Actor destrava agora — não uma lista estática cravada no binário no momento do build.

Onde o código executa e qual Actor ele segura são perguntas separadas

PerguntaRespostas
Onde este código executa?uma função de nuvem · um cliente de jogo · um host master-client ou servidor dedicado
Qual interface de Actor ele segura?player · room-owner · entry-validator · seller · moderator · backend-service · …

Dispostos em grade, os dois eixos são independentes e toda célula é alcançável:

função de nuvemcliente de jogohost da Roomadmin
player✓✓✓—
room-owner✓✓ — a própria máquina de um jogador, hospedando✓—
backend-service✓—✓✓

A célula em destaque é código rodando num cliente e fazendo o trabalho de um servidor. Ela tem nome — room-owner — e é uma concessão como qualquer outra.

Qualquer combinação é legítima. Uma função de nuvem não é automaticamente privilegiada, e um cliente não é automaticamente limitado: os direitos vêm da concessão, e a concessão é declarada. Os direitos do código são os mesmos onde quer que ele rode — só o que lhe foi concedido difere.

A revogação surte efeito sem reemitir a credencial

Uma credencial nomeia uma identidade. Ela não carrega uma lista de papéis. Os papéis resolvem no servidor, por requisição, o que significa que o cliente nunca segura a prova das próprias permissões e não há nada obsoleto para continuar apresentando depois de uma revogação.

Duas consequências, ambas declaradas onde lhes cabe:

  • Revogar um papel tem efeito sem reemitir a credencial — veja conceder um papel.
  • Ela se torna observável no máximo até o limite declarado de obsolescência do cache de direitos. Não prometemos instantâneo.

Onde a autoridade é declarada, não inferida

  • Um tipo de Room declara o modo de autoridade dele, e não há padrão: ou a nossa simulação conduz o Tick, ou uma autoridade externa o faz — o servidor de jogo do estúdio, ou o cliente de um jogador como master-client. Isso é Quem conduz o Tick.
  • Até onde uma autoridade externa é confiada quanto ao desfecho é uma Declaration separada no tipo de Room — aceitar, conferir com um Hook, ou não aceitar. De novo sem padrão.
  • Qual credencial resolve para qual papel, e o que é uma chave de host, é Access & Roles.
How the SDK works

Access & Roles

Papéis são compostos, nunca embutidos no código. Permissões atômicas se compõem em papéis; os papéis limitam os dados até a linha e a coluna e decidem quais interfaces de módulo um build sequer enxerga. Isso substitui a separação de chaves cliente/servidor: uma credencial nomeia uma identidade, e os papéis dela resolvem a cada requisição.

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

Quando usar

  • Você precisa de uma credencial mais estreita que "cliente" ou "servidor" — papéis compostos resolvem por trás dela a cada requisição.
  • O acesso a dados precisa parar em linhas e colunas: escopo por região, máscaras de PII, terceirizados só de leitura.
  • Um build deve enxergar apenas as interfaces que o papel dele destrava — kick/close simplesmente não existe para um visitante.
  • A sua interface precisa desabilitar botões com honestidade — CanI avalia a mesma política que o servidor vai aplicar.
  • Pule quando os presets entregues (player, room-owner, seller, …) já correspondem aos seus Actors — todo módulo os respeita por padrão; o catálogo completo mora em Conceitos essenciais.

Quem faz o quê

ActorNesta página
operatordeclara papéis e políticas, define limites de linha/coluna, concede papéis, emite chaves
match-organizera organização do torneio do fluxo abaixo: segura uma chave composta, controla inscrições, não pode reembolsar
every actorverifica CanI antes de agir; enxerga apenas as interfaces que destravou

De relance

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

Um átomo é um par — um recurso e um de quatro verbos: read, write, execute, administer. O conjunto de verbos é fixo, e um caso que não caiba divide o recurso em vez de aumentar a lista. É por isso que controlar a entrada de outra pessoa é Rooms.Membership.Administer e não um verbo ValidateEntry próprio: agir sobre a associação de outro Actor é administração, enquanto entrar você mesmo é Rooms.Membership.Write sobre o mesmo recurso.

O modelo

O ACL de dados é papel × operação × predicado de linha × máscara de campos — um modelo, idêntico quer tenha sido escrito em código, pela API ou na grade de papéis do painel.

De que o acesso é feito.

TermoO que é
atomum par recurso × verbo. Os quatro verbos são read (buscar, selecionar, assinar), write (criar, alterar, apagar e agir por si — entrar, sair), execute (invocar uma função, aplicar uma ability) e administer (agir sobre outros: kick, close, forçar uma mudança de estado)
roleum conjunto nomeado de átomos. Pode incluir outro papel, e um ciclo na inclusão é erro de configuração em vez de algo resolvido em runtime
role presetvem sobre os átomos e continua funcionando sem alteração para consumidores já implantados. Um ponto de partida, não uma restrição: um projeto declara papéis próprios com os mesmos átomos
row predicatequais linhas — um predicado booleano sobre valores da sessão
field maskquais campos, declarada por papel e por operação. Um campo que um papel não pode ler não é devolvido de forma alguma, em vez de devolvido vazio

Para que uma credencial resolve.

CredencialO que destrava
player keyum build de engine a carrega; o jogador por trás dela chega com o sign-in, e o build enxerga as linhas cl de toda tabela de Operations
host keyum servidor dedicado ou um master-client a segura, e os papéis dela destravam as linhas mc
pushed coderoda com o papel backend-service do projeto — é isso que o Authoritative = true de um Leaderboard confere
a registered hooknão concede nada a mais: a sua função mantém o papel sob o qual foi implantada

A legenda das etiquetas (fn / cl / mc / adm) pertence a Conceitos essenciais.

O que vale para toda verificação.

SempreO que é
a credentialnão carrega lista de papéis: ela nomeia uma identidade, e os papéis resolvem no servidor a cada requisição. Uma revogação invalida a resolução em cache de imediato, em vez de esperar o limite declarado de obsolescência
delegationmuda escopo, nunca capacidade: agir em nome de um jogador muda quais linhas ficam visíveis e a quem uma escrita é atribuída, e não concede operação alguma que o Actor já não tivesse
the verbresponde que tipo de efeito, e o predicado responde quais linhas. Se dois casos diferem apenas em de quem é a linha, isso é predicado; se o efeito em si difere, isso é outra operação e possivelmente outro verbo — que é por que "kick" é administer e não write com predicado largo
a hidden rowresponde not found: uma recusa não pode virar um oráculo de existência
an ownersempre enxerga a si mesmo, diga o predicado o que disser
visibilitynão é segurança — uma otimização de canal e uma permissão são mecanismos diferentes, e nenhum substitui o outro
a disabled modulenão tem superfície: se um módulo está habilitado é propriedade do build, então a geração de código não emite nada para um desabilitado e uma chamada indisponível é erro de compilação em vez de recusa em runtime
a module's surfacesegue o Actor em vez do lado (Autoridade o argumenta, e isto é o mecanismo dele): um build room-visitor enxerga join, leave e leitura; um build room-owner enxerga além disso kick, close e configurar; e whoami reporta quais interfaces o Actor atual destrava
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

Quem entrega um papel. Conceder e revogar um papel a um jogador, e o papel padrão declarado pelo projeto para um jogador novo, são Operations de Auth & Players — aquele módulo é dono das identidades, e um papel é resolvido pela identidade na credencial. Esta página é dona do que um papel é; aquela é dona de entregá-lo.

Erros

  • O que um predicado esconde responde not found, não forbidden — do contrário a própria recusa conta a quem chamou que a coisa existe, que é exatamente para o que escondê-la servia.
  • Um direito que quem chama não segura responde forbidden onde a existência do sujeito não é segredo, e nomeia o que faltava em vez de falhar em branco.
  • Um campo fora da máscara está ausente da resposta, não presente e vazio: um valor vazio e um mascarado seriam indistinguíveis.
  • Um papel que se inclui, direta ou indiretamente, é erro de configuração — recusado como Declaration em vez de resolvido em runtime.
  • A delegação nunca amplia capacidade: uma chamada que o Actor não poderia fazer em nome próprio é recusada quando feita em nome de um jogador.

Limites

Cada teto nomeia o comportamento na fronteira; os números chegam com o capítulo de limites da plataforma.

  • O tamanho de uma seleção sob um predicado de linha é limitado, e o modelo de ACL declara esse limite em vez de descobri-lo. Uma leitura acima do teto é respondida com o valor do teto em linhas mais o marcador que diz que foi cortada, nunca com uma página curta silenciosa.
  • O limite de obsolescência de uma permissão resolvida é declarado, e uma revogação não o espera — ela invalida de imediato.

Fluxo do usuário

Uma chave de organizador de torneio, da composição do papel até uma mudança de permissão ao vivo.

How the SDK works

Como o SDK é construído

Duas perguntas são confundidas uma com a outra, e ambas têm respostas curtas. Como o SDK é escrito — por que uma mesma ideia aparece um pouco diferente em Python e em Unreal C++. Como o SDK roda — o que fica entre a sua chamada e o fio. Esta página responde às duas de uma vez, para que nenhuma página de módulo precise fazê-lo.

Escrito do geral para o particular

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

O SDK é um desenho com duas saídas de estreitamento, e a ordem é a regra: nada desce um nível enquanto o nível acima genuinamente ainda puder carregar.

NívelO que mora aqui
Os princípios comunsIdênticos em todo binding: o comportamento é declarado como atributo ao lado do que ele descreve; toda Declaration que você envia é uma Declaration que o painel administrativo desenha; o seu código endereça módulos e mais nada.
A forma da linguagemSó o que o paradigma de uma linguagem não consegue exprimir do jeito comum. C# tem atributos e Python tem decoradores — a mesma Declaration, escrita como cada linguagem já escreve essa ideia. Uma linguagem sem construção equivalente carrega a mesma Declaration por outro caminho, e esse portador é nomeado onde se aplica em vez de ser presumido.
A forma da engineSó o que uma engine de jogo remodela sobre a linguagem dela. Unreal C++ não é C++ comum — tem modelo de objetos próprio e reflexão própria em tempo de build, então uma Declaration ali viaja dentro da macro de reflexão da própria engine, na posição em que essa macro já aceita especificadores. O C# da Unity também não é o C# do servidor: runtime mais antigo, biblioteca base menor.

Lido de cima para baixo, é por isso que as seis abas de cada exemplo não são seis APIs diferentes. São um API, escrito de seis jeitos, e as diferenças que você vê são os dois níveis de baixo aparecendo.

Como roda, do seu código para baixo

O seu código de jogo vê módulos. Isso não é uma simplificação para a documentação — é o contrato inteiro do nível de cima.

  • Módulos são o que você endereça. Eles formam um grafo, não uma árvore, e o que isso rende é Inheritance & Composition.
  • Os quatro Primitives são aquilo com que os módulos são montados — Events, RPC, Data & Subscriptions, Groups. Uma Room, um chat e uma fila de Matchmaking são o mesmo Primitive group sob regras diferentes. Se uma funcionalidade não pode ser exprimida pelos quatro, isso é defeito de desenho, não argumento por um quinto.
  • O hub fica embaixo, e você nunca o chama: injeção de dependências, montagem de módulos, a sessão do usuário, a recuperação de estado e a qualidade de entrega das mensagens. Ele é nomeado uma vez, em Por baixo do capô.
  • Os adaptadores de transporte ficam no fundo, um por protocolo, e o hub os esconde por completo. Serão vários — WebSocket, o nosso UDP, HTTP — e qual deles carrega uma chamada não é algo que o seu código decida ou perceba.

A única coisa que um módulo lhe conta sobre entrega é a qualidade dela — pelo menos uma vez, ou no máximo uma vez. Todo o resto sobre como os bytes chegaram lá deliberadamente não lhe cabe saber, porque é a parte que reservamos o direito de tornar mais rápida.

O que você ganha com isso

  • Um SDK, não um de cliente e um de servidor. O que uma chamada pode fazer é a concessão do Actor, não uma flag de build. Isso é Autoridade, e é a decisão mais consequente desta página.
  • Uma Declaration é a entrada de tudo. Envie-a e a API tipada aparece, o painel administrativo a desenha, e a geração de código de cada binding vem atrás. Veja Schema as Code.
  • Os módulos se compõem em vez de herdar. Como, e o que "herança" honestamente quer dizer aqui, é Herança e composição.
How the SDK works

Threads, tempo de vida e testes

O laço é seu. Nós entregamos em exatamente um lugar, e nunca pelas suas costas. O SDK não inicia thread alguma que você precise conhecer, não lhe entrega lock algum e chama o seu código a partir de um único contexto que você escolheu na inicialização. Chame-nos de qualquer thread que quiser; nós chamamos você de uma.

Um contexto de entrega, e o laço é seu

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

Uma instância declara exatamente um contexto de entrega — o único lugar onde todos os handlers dela rodam. Ele é fixado quando você inicializa e não muda pelo resto da vida da instância. Um Event, um Delta de dados, o desfecho de uma chamada: todos chegam ali e em nenhum outro lugar.

Ele tem duas formas, e você escolhe uma na inicialização:

  • Você bombeia. O runtime não faz nada por conta própria; você drena as entregas pendentes a partir do seu próprio laço. É a forma que uma engine quer — as entregas caem na thread do jogo, num frame que você escolheu.
  • Nós é que somos donos. O runtime mantém uma thread de execução dedicada. É a forma que um host de console ou um servidor dedicado quer.

Nenhuma é reserva da outra, e não há terceira opção envolvendo pool de threads. O sentido de prometer um contexto é justamente que você nunca precise perguntar quantas threads nós criamos.

O contexto nunca é um argumento. Nenhum handler recebe um parâmetro "em qual thread estou", e não há nada a consultar. Onde o seu handler roda é propriedade do contrato, não dado da chamada.

Iniciar e parar são explícitos

A inicialização é uma chamada que você faz, e ela responde com um desfecho. Nada inicializa preguiçosamente no primeiro uso — isso é proibido, e não meramente desaconselhado, e a razão vale uma frase: um início preguiçoso move o único lugar onde um módulo desabilitado fica visível para a chamada arbitrária que por acaso veio primeiro, onde ele se lê como aquela chamada falhando.

Um módulo desabilitado é nomeado na inicialização, e o desfecho diz qual das duas coisas aconteceu: a cadeia dependente inteira está desligada, ou você está rodando com menos, mais a lista do que está indisponível. Não há um terceiro caso silencioso.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Parar não cancela nada. Este é o único ponto onde um hábito da maioria dos SDKs é ativamente errado aqui. A parada é explícita, completa e idempotente — depois que ela tem êxito, nenhum handler daquela instância é chamado de novo —, mas ela não diz nada sobre trabalho já em voo. Uma operação que você iniciou antes de parar continua descobrível pelos meios que aquela operação nomeou. Se você precisa saber se uma compra passou, parar não é como se descobre.

Todo handle tem um fim declarado — e ele nunca é o coletor de lixo

Uma assinatura, um descritor de trabalho adiado, uma sessão: cada um é um handle, e cada um tem exatamente um fim que o contrato nomeia. Liberar é idempotente, então liberar duas vezes não é erro.

Três consequências fáceis de errar:

  • O fim nunca é um finalizador, um destrutor ou um escopo. Um handle que você deixa cair no chão continua aberto. Isso é um bug no seu código, não algo que recolhemos em silêncio — porque um tempo de vida que dependesse da linguagem seria um tempo de vida diferente em cada binding.
  • Usar um handle depois do fim dele é uma recusa declarada, com código. Não um resultado vazio, não comportamento indefinido, e não um erro genérico de objeto descartado que não carrega nada acionável.
  • Uma conexão caída não é o fim de um handle. Uma assinatura sobrevive a uma desconexão e continua recebendo depois da reconexão. Handles terminam pelas razões que o contrato nomeia, e perder a rede não é uma delas.

Nenhum handle sobrevive à instância que o emitiu: assim que você para, todo handle que ela lhe deu está no fim dele.

Chamar de dentro de um handler tudo bem; esperar dentro de um, não

Chame a superfície de qualquer thread sua. Todo handle é livre de thread, e isso é uma promessa e não uma propriedade do build de hoje. Você nunca vai tomar o nosso lock, esperar na nossa barreira ou ouvir que algo deve ser chamado "sob um lock" — primitivo de sincronização algum faz parte da superfície.

Os handlers de uma instância são serializados: dois nunca rodam ao mesmo tempo, e a ordem dentro de um fluxo é preservada. Então um handler não precisa de trava própria.

Serializado não quer dizer deduplicado. Ordem é uma promessa; quantas vezes uma mensagem é entregue é outra, declarada no tipo da mensagem. Sob "pelo menos uma vez" você verá a mesma mensagem duas vezes, e a chave de deduplicação que sempre viaja com ela é como você distingue.

Iniciar uma operação de dentro de um handler é legítimo e não pode causar deadlock. O desfecho dela, porém, nunca chega dentro do mesmo handler — ele volta como uma entrega separada no mesmo contexto. Ir para dentro é permitido; virar-se lá dentro não é.

Bloquear o contexto de entrega é proibido, e a proibição não é conselho. Esperar a rede, esperar o lock de outra pessoa, esperar de forma síncrona a sua própria chamada: tudo proibido dentro de um handler. A proibição tem um sintoma — um handler que segura o contexto além do orçamento declarado produz ou uma degradação declarada da entrega ou uma recusa declarada. O que ele nunca produz é uma lentidão silenciosa que você vai descobrir na sessão de um jogador.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

"Cancelar" são duas coisas diferentes

Uma palavra na maioria das linguagens, duas operações aqui, e a diferença é observável:

Você querO que é
parar de me entregarlocal. Sempre tem êxito, inclusive com a conexão caída. Liberar uma assinatura é isto.
parar o trabalhoum pedido à plataforma. Idempotente, e não promete nada sobre o trabalho ter acontecido ou não.

O segundo é o que as pessoas erram. Cancelar trabalho aceito é um pedido que pode não chegar a tempo — exatamente como um timeout, que também não quer dizer "não se aplicou". Depois de qualquer um dos dois tipos de cancelamento, o desfecho de uma operação não idempotente já iniciada continua descobrível pelos meios que aquela operação nomeou.

E as duas falhas são distinguíveis: cancelar uma chamada que nunca nos alcançou é falha local; cancelar trabalho que havíamos aceitado lhe dá um estado terminal de um conjunto declarado.

O que você recebe é uma cópia do passado

Um valor entregue ao seu handler não muda depois. Nunca entregamos uma referência viva para o nosso próprio estado, então nada do que você segura sofre mutação entre duas linhas do seu código.

Guardar um valor entregue além do handler é, portanto, seguro — mas o que você guardou é a observação de um momento, não uma janela para o presente. Deltas podem ser fundidos a caminho de você, então uma lista de valores que você salvou não é um histórico do que aconteceu.

Você também nunca é dono dos nossos buffers. Não há alugar-e-devolver, não há montar-e-enviar: mudar estado declarado é a operação de rede.

Testes: uma implementação em memória, não um mock

Existe uma implementação em memória completa — mesma superfície, mesmo conjunto de desfechos declarados, sem rede. É uma coisa separada da qual você depende, não uma flag no runtime de produção.

  • Ela não é parcial. Uma operação que ela não suporta é recusada com um código declarado, nunca respondida com um sucesso inventado. Um teste que passa contra ela passa por um motivo.
  • O tempo é seu. Prazos declarados — o tempo de vida de um descritor de trabalho, uma reserva, uma janela de retenção — são alcançados avançando um passo, não dormindo.
  • O determinismo é declarado e limitado: ordem dentro de um fluxo, o modo de entrega declarado, tempo controlável. Determinismo de ponto flutuante não é prometido, então uma simulação completa também não se reproduz aqui.

A diferença em relação a um mock é o ponto. Um mock verifica que você chamou o que pretendia chamar. Isto verifica que o que você chamou faz sentido.

O que você instala, e o piso de versão

O core é uma unidade; os módulos opcionais são unidades separadas, cada uma com composição declarada e lista declarada de dependências obrigatórias. Acrescentar uma unidade nunca muda a superfície de outra — um módulo monta onde a Declaration dele diz, então nada aparece ou desaparece em outro lugar por causa do que você instalou ao lado.

Se uma unidade opcional é referenciada mas não consegue carregar, isso é um desfecho declarado da inicialização — o mesmo lugar onde um módulo desabilitado é reportado. Nunca um stub que silenciosamente não faz nada.

Todo binding declara a versão mínima de runtime contra a qual foi construído. Abaixo dela você recebe uma recusa na inicialização, não operação parcial: um runtime velho demais quebraria de outro modo na primeira capacidade que lhe falta, que fica em algum ponto arbitrário do seu código e normalmente na máquina de um jogador, não na sua. Elevar esse mínimo é uma mudança quebrante e passa pelo mesmo processo que qualquer outra.

Para onde ir depois

Building blocks

Core

Core é o único objeto que você cria, e todo o resto pende dele. Uma chave na entrada, e você tem contexto, identidade, falhas tipadas, rastreamento e lotes. Toda chamada de módulo passa por ele, e nenhum módulo traz a sua própria versão.

Who addresses Core, the 4 things it provides, and the 1 module it builds on

Quando usar

  • Você precisa saber quem e onde você é — identidade, papéis, módulos destravados, project · env · região, tudo no único objeto que você segura.
  • Uma função de nuvem precisa escrever como um jogador — a escrita é atribuída a esse jogador, e o registro nomeia as duas partes: a função e o jogador.
  • Repetições nunca podem aplicar duas vezes — Operations em lote carregam uma chave de idempotência.
  • Uma falha precisa ser ramificável e pesquisável — todo lançamento é um Problem tipado com código estável.
  • Pule quando você quer mensageria, chamadas ou estado — esses são os Primitives: Events, RPC, Data & Subscriptions.

Quem faz o quê

ActorNesta página
any actorlê identidade, contexto e papéis por Whoami
backend-serviceage como um jogador; agrupa Operations idempotentes em lote
operatorlê rastros de chamadas que falharam ou foram repetidas

De relance

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

A identidade mora dentro do próprio Core. Nenhum parâmetro session ou ctx jamais aparece numa chamada.

O modelo

O que todo erro carrega.

CampoO que é
codeo nome da recusa, legível por máquina, e ele é estável. O vocabulário é uma projeção dos códigos que a plataforma já tem: um código novo para uma recusa que a plataforma já nomeia é proibido
categorya classe em que a recusa se encaixa, que é o que diz se repetir faz algum sentido
trace identifiero identificador desta ocorrência específica, presente sempre, inclusive em erros locais, para que falar com o suporte nunca exija reproduzir a falha antes
explanationtexto para uma pessoa ler, e não é estável: títulos e explicações mudam e são localizados a qualquer momento
per-field errorsa lista que uma recusa de validação carrega — campo, código e mensagem para cada campo rejeitado

Um consumidor ramifica pelo código e pela categoria, nunca por texto humano — nem por comparação, nem por substring, nem por parsing. Um erro do qual só a mensagem é alcançável é um defeito do binding em vez de uma forma a contornar.

Três origens, e não são a mesma coisa.

OrigemO que aconteceu
platformela respondeu com uma recusa, carregando um código do catálogo da plataforma
localo SDK recusou antes de enviar, a partir do vocabulário publicado dele
unknowna chamada foi enviada e resposta alguma voltou. Nem "a plataforma disse não" nem "nós nunca perguntamos"

O que vale para toda recusa.

SempreO que é
a refused operation applied nothingatomicidade é obrigação da plataforma, não sua: nada de leitura compensatória num ramo de erro comum. Exatamente dois casos são exceção e ambos o dizem onde surgem — um timeout, cujo desfecho é desconhecido, e um lote sob semântica por elemento
the delivery pathnão muda o erro: o mesmo código, categoria e origem chegam até você, quer o binding lance, devolva um valor de resultado ou chame de volta numa assinatura. Um caminho que carregue menos que outro é defeito daquele binding
a timeoutnão é um desfecho: é a terceira origem acima, e o que fazer a respeito é declarado por operação em vez de adivinhado

Core carrega o contexto, não as mensagens. Emitir e assinar fatos é o Primitive Events; chamadas — requisição/resposta, unidirecionais, fan-out por grupo — são o Primitive RPC; estado, assinaturas e leituras em fluxo são o Primitive Data & Subscriptions, endereçado por Entity. As audiências para as quais os três se espalham são o quarto Primitive, Groups. Transferências dimensionadas (uploads, downloads) afloram em Files & UGC. A semântica de montagem — espaços de nome, rejeição de colisão no momento da montagem — mora em Por baixo do capô.

Erros

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

O que vê quem chama sem o papel. As linhas fn e adm são recusadas, não degradadas: um jogador ou uma sessão de cliente que chame "agir como jogador", emita um rastro ou leia um, recebe um Problem com código forbidden — a credencial é válida, os papéis por trás dela não carregam esse direito, e repetir a chamada com a mesma chave de idempotência não muda nada. Não existe variante reduzida que rode com menos direitos e devolva menos.

Toda falha é um Problem tipado com código estável: os mesmos códigos que o contrato do fio documenta, para que um cliente possa ramificar por eles e uma pessoa possa pesquisá-los.

Limites

Um limite é aplicado na admissão. Uma chamada que foi aceita já passou pelo limite e será executada, por mais que espere para ser processada; uma recusa que caia sobre alguma chamada posterior não faz nada com o trabalho já admitido. Então uma fila que encheu é uma fila que anda — repetir a chamada aceita porque uma vizinha foi recusada é como se faz o trabalho duas vezes.

Você aprende um limite sendo recusado, e não há mais nada a ler. O SDK não expõe nem o valor em vigor, nem a folga restante, nem um aviso de aproximação, e nada sobre um limite jamais é posto diante de um jogador. A recusa carrega tudo o que há:

  • a categoria, que é o que diz se uma repetição faz algum sentido
  • de quem era o limite
  • quando uma repetição é permitida, e sobre qual janela

Ramifique por isso. Não há contador para consultar nem orçamento para exibir.

Fluxo do usuário

Uma chamada que falha, do lançamento até o rastro que um operador lê.

Building blocks

Events

Um Event é o fato de que algo aconteceu, entregue a todos que devem ouvir. Use-o para o que acontece uma vez e não pode ser recuperado a partir de um valor atual — um tiro, uma compra, uma entrada em Room.

Who addresses Events, the 5 things it provides, and the 1 module it builds on

Quando usar

  • Algo aconteceu e outros precisam reagir — um tiro disparado, uma porta trancada, uma partida encerrada.
  • A audiência varia — a mesma emissão alcança um squad, uma Room ou um Actor, decidida pelo target que o tipo declara.
  • Você quer handlers tipados com autocompletar — um Event declarado vira send. e on. na superfície dele, cada um com o contrato próprio.
  • O fato ainda precisa ser legível uma hora depois — declare o tipo como retido e leia-o de volta por período.

Quem faz o quê

ActorNesta página
schema-authordeclara Events com [Event], envia o schema
any actoremite por send., assina por on.

De relance

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

Um Event declarado dentro de um módulo ou Group aflora só ali: squad.send.rallyCall existe porque rally_call é declarado para squads, e a emissão alcança os membros do squad. Um Event que um módulo emite para fora é parte do contrato declarado dele; quem chama nunca fica sabendo de Events não declarados.

O modelo

O que um tipo de Event declara.

DeclaraO que é
nameum nome de fio explícito, declarado em vez de derivado do símbolo
payloado schema do que uma emissão carrega
targetpara onde vão as emissões deste tipo: uma instância de Entity, um Group, uma Room ou o contexto global. Um target de Group é entrega em massa — um sinal, muitos destinatários. Um destinatário que muda se exprime como Group, nunca como endereço passado na emissão
clocksim_time ou timestamp, nunca os dois — sim_time para fatos dentro de uma simulação, que participam de Prediction, compensação de lag e rollback; timestamp para fatos fora dela, como uma compra ou um sign-in
retentiontransient — alcança quem estiver assinando no momento da emissão e não é guardado; ou retained — guardado e lido de volta por tipo e período, não pela superfície de consulta que Data & Subscriptions carrega. Declarado, nunca inferido do tipo de Event
termnum tipo retained: por quanto tempo é mantido, e o que acontece no vencimento. "Para sempre" não é um dos valores
deliveryno máximo uma vez, pelo menos uma vez ou exatamente uma vez — declarado no tipo, para que um assinante nunca precise perguntar qual uma emissão usou; "exatamente uma vez" declara os limites dentro dos quais vale
contexto contexto em que o tipo é declarado, global ou local. Um nome declarado globalmente é visível em contextos locais; um declarado localmente não é visível acima. O que um módulo emite é contrato dele de qualquer forma — quem chama nunca fica sabendo de um Event não declarado

O que uma emissão carrega.

CampoO que é
typeo Event declarado. Duas emissões nunca se fundem: dois tiros são dois Events, e o segundo não absorve o primeiro — que é o que separa um Event do campo [Sync] que Data & Subscriptions carrega
payloadconforme o schema do tipo
sourceo Actor que emitiu, mais a instância dele quando foi uma Entity que emitiu. Um Event emitido por um cliente é uma alegação, não um fato: o lado autoritativo o confere antes que algo dependa dele
stamppelo relógio declarado do tipo
dedup keypresente sob todo modo de entrega, porque reentrega é possível em todos eles — um duplicado de transporte, uma segunda leitura de um Event retido
cause keynum Event que a plataforma emite por causa de outro Event da plataforma: o id daquilo de que ele decorre, para que uma cadeia seja reconstruída por chave e nunca comparando carimbos

O que uma assinatura segura.

SeguraO que é
evento tipo declarado ao qual está ligada
surfaceo nó em que foi tomada, dentro do target declarado do tipo — a metade da audiência que cabe ao assinante
handlertipado pelo payload
positionde onde ela retoma, declarada, para que uma reconexão não recomece silenciosamente em "agora". O que foi perdido no intervalo não é reproduzido: um Event transient é irrecuperável, e só um retained pode ser lido de volta

O que vale para todo Event, declare o tipo o que declarar.

SempreO que é
audiencenunca é enumerada por quem envia: é o target declarado do tipo estreitado a quem estiver assinando, e então filtrado por Access & Roles — publicar e assinar são direitos separados e nenhum implica o outro, e um fluxo pode ser fechado por um predicado mesmo onde o tipo em si é visível. Quem envia e pudesse listar destinatários teria de reproduzir o que Groups e Data & Subscriptions já sabem
phasesemitido, depois entregue — e nada mais. Um Event não tem máquina de estados: ele acontece uma vez
orderingprometida dentro de um fluxo, e para Events um fluxo é uma instância emissora: dois Events da mesma instância chegam na ordem de emissão. Entre fluxos ordem alguma é prometida sob forma alguma — nem entre duas instâncias, nem entre um Delta e um Event sobre a mesma mudança
crossing streamsquando é preciso ordem entre fluxos, o mecanismo é declarado, nunca suposto: traga as mensagens para um fluxo só, ou carregue um carimbo causal no payload
gap detectiononde o modo admite perda, o assinante fica sabendo do intervalo em vez de pulá-lo em silêncio

Se um fato é mantido depois da entrega é um campo da Declaration dele, não uma decisão tomada na emissão — então o mesmo tipo é sempre mantido do mesmo jeito e nenhum chamador precisa lembrar qual chamada era qual.

TransientRetained
Alcançaquem estiver assinando naquele momentoisso, e um assinante que chegar depois
Depoisacaboumantido por um prazo declarado
Legível de voltanãosim, dentro do prazo
Passado o prazo—uma seleção recusa, em vez de responder vazio
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

Erros

  • Publicar e assinar são direitos separados, e nenhum implica o outro. Uma assinatura sem o direito responde forbidden, não not-found — o tipo está no contrato declarado do módulo, então não há nada a esconder.
  • Assinar um tipo que o módulo não declarou é erro de contrato, aflorado como Problem tipado — nunca um no-op silencioso.
  • Na emissão, três recusas de validação: um tipo não declarado, um payload que não passa no schema, e um target que o tipo não permite.
  • Uma seleção além do prazo de um tipo retido recusa, em vez de responder com uma página vazia.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Tamanho do payload — acima do teto a publicação falha e o Event não acontece, nunca um payload truncado.
  • Taxa de publicação por origem — uma recusa por limite de taxa carregando o momento de repetir.
  • Assinaturas por Actor — a nova é recusada e as existentes mantidas.
  • Volume de retenção por tipo — descarte pela política declarada, por prazo, nunca ao acaso.

Fluxo do usuário

Uma convocação, da Declaration ao marcador que cada membro do squad vê na própria tela.

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

RPC

Uma chamada tipada cujo corpo mora em outro lugar. RPC é o segundo Primitive: declare o procedimento onde ele pertence — num módulo, ou dentro de uma Entity — e todo binding ganha um método gerado e aguardável. O verbo é invoke: unidirecional é um modo que a Declaration nomeia, não um segundo verbo, e não existe do.

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

Quando usar

  • Quem chama precisa de uma resposta — requisição/resposta com retorno tipado.
  • Quem chama reporta e segue em frente — um RPC unidirecional declarado, nada volta.
  • O trabalho dura mais que a chamada — um RPC adiado declarado devolve um descritor de trabalho em vez de um timeout.
  • Uma pergunta, muitos respondentes — uma chamada de grupo são N chamadas, e cada resposta chega ligada ao membro que a enviou.
  • O verbo pertence a uma coisa — declare-o dentro da Entity; o RPC de uma Entity não mora em nenhum outro lugar (Entity mostra a Declaration).
  • Pule quando ninguém está sendo chamado a agir — um fato ao qual outros apenas reagem é um Event.

Quem faz o quê

ActorNesta página
schema-authordeclara RPCs, os modos deles e quem pode chamá-los
any actorinvoca uma chamada com resposta ou unidirecional, onde a Declaration permite
group memberresponde a uma chamada fan-out; uma resposta volta por membro

De relance

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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);

Onde o corpo executa — função de nuvem, cliente, master-client ou servidor de jogo — é roteamento, declarado por método; Extensibility cobre sobrescritas e middleware. Uma chamada que falha lança um Problem tipado (Core).

O modelo

O que um RPC declara.

DeclaraO que é
namedo vocabulário de verbos
inputos argumentos que quem chama precisa escolher
outputexatamente um tipo declarado. Uma resposta mais curta é um tipo declarado próprio, nunca o mesmo tipo com campos calados — do contrário "não foi pedido", "o objeto está ausente" e "escondido pela máscara de acesso" viram uma ausência indistinguível
reply modewith a reply — um valor do tipo de saída declarado ou uma recusa tipada; ou one-way — sem resposta, e quem chama só fica sabendo de uma falha local de envio. Unidirecional não deve ser usado onde quem chama precisa do desfecho: um desfecho desconhecido custa mais que uma recusa conhecida
execution modeimmediate — o desfecho volta dentro da chamada; ou deferred — a chamada devolve um descritor de trabalho e o desfecho é lido ou chega por assinatura. Declarado, nunca escolhido pela implementação conforme a carga, porque quem chama constrói o comportamento dele sobre a forma da resposta
streamingse a entrada e a saída chegam em partes e são tratadas conforme chegam, em vez de por inteiro
idempotencyum RPC unidirecional também carrega chave de idempotência: não haver resposta não quer dizer não haver reentrega
overridabilitydeclarada no próprio método. Nenhuma Declaration significa não sobrescrevível — nunca sobrescrevível por padrão
contextonde ele é declarado. Um RPC declarado dentro de uma Entity é parte daquela Entity e não existe fora dela. Declarar um no servidor de jogo é registrá-lo no roteador — não há um segundo jeito de adicionar um

O que uma invocação carrega.

CarregaO que é
argumentssó o que quem chama precisa escolher
implicit contexto receptor, quem chama e o contexto ambiente, ligados antes do seu primeiro parâmetro escrito — a um método de Entity nunca se pede o identificador daquela Entity
referencesum argumento que é objeto do SDK viaja como um Ref tipado — um identificador ou um cursor, nunca uma cópia do conteúdo. O destinatário o resolve em nome próprio, sob as mesmas permissões e predicados: uma referência é um endereço, não uma permissão concedida
outcomeum valor do tipo de saída declarado, ou um Problem tipado

O que o descritor de uma chamada adiada segura.

SeguraO que é
stateaccepted → running → completed ou failed, os dois últimos terminais
lifetimedeclarado; passado ele o desfecho fica indisponível e pedi-lo é uma recusa, não uma resposta vazia
cancelidempotente, e honesto: ele pede, e o estado terminal que você observa é qual dos dois — completed ou failed — o trabalho alcançou

O que vale para todo RPC.

SempreO que é
one handlerexatamente um handler lógico — que é o que separa um RPC de um Event, onde pode não haver nenhum. Então endereçar um Group são N chamadas e não uma: Groups fornecem os endereços, e as respostas voltam como fluxo, cada uma ligada ao membro que a enviou
meaningum pedido para realizar uma ação, enquanto um Event é a afirmação de um fato. Um RPC unidirecional e um Event parecem iguais de fora e não são a mesma coisa: o handler de um RPC é obrigado a existir, um Event pode não ter destinatário algum e isso é normal
no state machineuma Declaration não tem, e uma chamada imediata não tem — ou ela devolveu um desfecho ou não, e então valem as regras de timeout. Só uma chamada adiada tem estados observáveis
a streamnão é atômico: uma saída em fluxo não promete nada sobre o todo — o receptor tem de estar pronto para uma interrupção e para distinguir "o fluxo completou" de "o fluxo foi interrompido"
no predicate on a writeescrita alguma aceita um predicado como entrada: "faça isto para todos que satisfaçam esta condição" não é uma operação. Uma ação em massa se exprime por enumeração — leia o conjunto, entregue a lista a uma operação em lote com semântica de falha parcial declarada. Como entrada de uma escrita, um predicado é avaliado num momento que ninguém nomeou, sobre um conjunto que ninguém viu

Todo RPC alcança o handler dele pelo roteador, e qual das direções dele responde é declarado por método em vez de ser propriedade do ponto de chamada — veja Extensibility.

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

Erros

  • O direito está no RPC, nunca no Primitive. Não existe um "pode invocar" geral: cada Declaration nomeia o átomo que quem chama precisa segurar, e quem não o tem recebe uma recusa forbidden tipada, com código e tudo — não um descarte silencioso.
  • Uma instância escondida se lê como "not found". Um RPC de Entity invocado numa instância que o predicado de linha de quem chama esconde responde exatamente como responderia ler aquela instância, para que a recusa não conte nada sobre o que existe.
  • A ausência de handler é "unavailable", não "not found". O RPC é declarado, logo existe; o que falta é rota. Essa recusa é repetível — um servidor de jogo pode voltar —, enquanto "not found" diria a quem chama para parar de tentar.
  • A recusa de um Hook carrega o código e a razão do próprio Hook, para que "rejeitado por uma regra do jogo" nunca chegue parecendo "o transporte quebrou".
  • Um timeout não é um desfecho. Para uma chamada adiada você lê o descritor; para uma imediata, a chave de idempotência declarada é o que torna a repetição segura — inclusive numa chamada unidirecional, onde a ausência de resposta não é ausência de reentrega.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Tamanho da entrada — a chamada é recusada antes da execução.
  • Tamanho da saída — recusada em vez de truncada, porque uma resposta aparada é indistinguível de uma completa.
  • Taxa de chamadas por Actor — recusa por limite de taxa nomeando quando repetir.
  • Chamadas adiadas simultâneas por Actor — a nova é recusada e as em voo terminam.
  • Tempo de vida do descritor — passado ele o desfecho fica indisponível, e isso é uma recusa.
  • Profundidade da cadeia de chamadas — uma recusa declarada ao excedê-la, nunca recursos esgotados nem uma quebra silenciosa.

Fluxo do usuário

Uma pontuação enviada, um ping reportado, um squad perguntado se está pronto.

Building blocks

Data & Subscriptions

Você muda um campo. Todo o resto rio abaixo acontece sem uma linha de código. Data é o terceiro Primitive: a mecânica sob todo campo sincronizado — Deltas contra o último estado confirmado, o aspecto como unidade de política, prioridade e taxa de envio, assinaturas retomáveis, a janela retida e Hooks antes e depois da mudança.

Você endereça Entities, não tabelas — veja Entity para a superfície de leitura e mudança (buscar, filtrar, ordenar, paginar, assinar uma seleção); esta página é a mecânica por baixo. Não há caminho de consumo até uma tabela, e não há um segundo jeito de escrever: uma mudança é uma Operation de Entity, e o Delta é o que decorre dela.

Who addresses Data, the 5 things it provides, and the 1 module it builds on

Quando usar

  • Você precisa de estado replicado para clientes sem código de snapshot — mudar um campo é a sincronização inteira.
  • Campos diferem em urgência ou audiência — prioridade e teto de taxa de envio por aspecto, e um predicado de visibilidade para névoa de guerra.
  • Um cliente reconectando não pode divergir em silêncio — um intervalo é detectado e nomeado, e um intervalo além da janela retida é respondido com o estado completo.
  • Você precisa do passado recente — a janela retida de Deltas, indexada por sim_time, é o que Prediction e a compensação de lag leem.
  • Uma regra de validação pertence a um lugar só — um Hook antes da mudança corta ou veta antes que ela caia.
  • Pule os botões quando tudo o que você quer é ler ou consultar — a superfície de Entity viaja sobre esta mecânica sem tocá-la.

Quem faz o quê

ActorNesta página
schema-authordeclara aspectos, a política de sincronização deles e o predicado de visibilidade
any actorassina um target; retoma de uma posição; pede o estado completo
backend-serviceHooks antes e depois da mudança
operatorlê o custo de pacote por Actor; vê quando a entrega degrada ou um pacote é cortado

De relance

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

O modelo

O que um Delta carrega.

CampoO que é
changed fieldsapenas eles, nunca o objeto inteiro
pairo par instância × aspecto a que pertence
numberum número de sequência dentro daquele par, que é o que torna um intervalo detectável

O que um aspecto declara.

Tudo por atributo, no aspecto ou num único campo, nunca por chamada em runtime. O aspecto define o padrão e um campo pode sobrepô-lo; o aspecto continua sendo a unidade de política, porque do contrário não haveria com que montar presets.

DeclaraValores, e o que não é
priorityordena o que é enviado primeiro quando o canal não dá conta. Não é promessa de latência: é relativa e ordena o envio entre campos em vez de garantir prazo de entrega
max update rateum limite superior de envio. Não é promessa de recebimento naquela taxa — receber depende do canal
delta onlynão enviar o que não mudou
delivery modeshared packet — a mesma coisa para todos, barato de CPU; ou per-actor packet — cada um o seu conforme a zona de visibilidade dele, caro de CPU e necessário em populações grandes
visibility ruleo predicado que decide quem recebe — Visibility projeta essa metade por inteiro
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

O que uma assinatura segura.

SeguraO que é
targetuma instância, uma seleção ou um aspecto, e ela recebe os Deltas daquele target. Um target não é um fluxo: um target pode cobrir muitos pares, e a ordem é prometida dentro de um par e não através do target
positionde onde ela retoma: quem consome a apresenta. Se o intervalo for maior que a janela retida, chega o estado completo em vez de um fluxo de Deltas, de modo que uma desconexão longa nunca deixa um cliente silenciosamente errado
stateactive → gap detected → resynchronised | closed, e closed é terminal

O que vale para todo fluxo.

SempreO que é
mergingDeltas o admitem: 100 → 90 → 80 entre envios pode chegar como 100 → 80, porque o estado final continua correto. É exatamente isso que separa um Delta de um Event, onde perder um perde informação de vez
gap detectionperder um Delta em silêncio é proibido; o número de sequência no par é o que quem consome conta
orderingvale dentro de um par instância × aspecto; entre pares não é prometida sob forma alguma
traversalpercorre apenas o declarado: o que pode ser filtro, ordenação ou inclusão é um campo declarado e uma referência declarada. A superfície de seleção de Entity é a projeção desse modelo, e este Primitive não dá a quem consome travessia própria — não há uma segunda linguagem de consulta
historyé construído de Deltas: a janela instantânea de uma Entity é uma janela retida de Deltas indexada por sim_time. A profundidade dela é limite deste Primitive, e ele não promete reprodutibilidade sobre campos de ponto flutuante
the packet budgetdegrada conforme declarado: quando o orçamento por Actor acaba, a plataforma recai no pacote compartilhado conforme declarado, em vez de começar a perder destinatários arbitrariamente

O que um Hook pode fazer, e quando.

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

HookO que pode fazer
antes de uma mudançaalterá-la, ou vetá-la. Uma mudança vetada não produz Delta algum — assinantes não veem nada, em vez de um valor e depois uma correção
depois de uma mudançaacrescentar efeitos colaterais, e nunca pode fazer a mudança falhar

Apagar é enganchado em Entity, onde o apagar mora; este Primitive engancha a mudança.

Erros

  • Um target de assinatura não declarado é recusa de validação.
  • Sem permissão para assinar responde forbidden ou not found conforme a existência do target seja ou não segredo — a recusa não pode virar um oráculo.
  • Uma posição de retomada que não faz parsing é requisição inválida, nunca um recomeço silencioso a partir de agora.
  • Uma assinatura fechada pelo lado da plataforma é conflito, e é observável: o closed da máquina é terminal e alcançá-lo não é algo que um cliente tenha de inferir.
  • A contagem de assinaturas esgotada é conflito — a permissão está lá, o espaço não.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • A janela de retenção de Deltas — retomar de algo mais antigo que a janela devolve o estado completo em vez de uma recusa.
  • Assinaturas por Actor — uma nova é recusada e as existentes continuam.
  • Tamanho do Delta — o Delta é dividido em vez de truncado, e a divisão é observável.
  • A taxa de envio — um limite superior, não uma garantia.
  • O custo de um pacote por Actor — ao esgotar, degradação declarada para o pacote compartilhado.

Fluxo do usuário

Uma mudança de posição, da atribuição ao movimento corrigido em toda tela.

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Groups

Uma lista, um ouvinte em massa. Um Group é o quarto Primitive: um conjunto nomeado de Actors que recebe como um só. Você endereça o Group e todo membro ouve — uma Room, um chat, uma fila de Matchmaking e uma lista de envio são o mesmo Primitive sob regras diferentes: lógica de entrada e saída diferente, tempo de vida diferente, a mesma lista por baixo.

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

Quando usar

  • Você precisa de parties, squads ou guildas — conjuntos nomeados de jogadores com capacidade declarada e, onde o tipo declarar um, tempo de vida.
  • A associação deve seguir uma regra declarada que a plataforma avalia — veteranos novos entram sem cron job e sem uma chamada sua de reavaliação.
  • Você quer endereçar muitos jogadores de uma vez: um Event declarado se espalha com send.*, um RPC declarado alcança todo membro e cada resposta volta nomeada.
  • Você precisa de um modelo de associação reaproveitado como audiência — um escopo de Visibility, uma conversa de Messaging, uma party de Matchmaking.
  • Pule criar um quando o conjunto são os membros de uma sessão — Rooms é este Primitive com regras de Room, e já os endereça.

Quem faz o quê

ActorNesta página
playercria Groups a partir de tipos declarados, entra e sai, adiciona ou remove membros, envia Events, invoca RPC fan-out; segurando o direito de administrar a associação de um Group, remove membros e o fecha
room-owneras regras de vaga de Room viajam neste Primitive (configuradas em Rooms)
backend-servicedeclara tipos de Group e as regras deles; engancha Hooks na entrada e na saída

De relance

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

O chat de uma Room é este Primitive com semântica de mensagem por cima: a Room declara o tipo de Group dela, monta-o no espaço de nomes da Room e deixa a própria associação da Room decidir quem está dentro — de modo que a lista do chat e a lista da Room nunca podem discordar.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

Nada sobre chat está no Primitive. A audiência e a superfície send.* vêm daqui; autor, thread e histórico vêm de Messaging, e quem pode administrar a associação é um direito próprio (Access & Roles), segurado pela Room.

O modelo

O que um tipo de Group declara.

DeclaraO que é
nameo do próprio tipo
membership modeexplicit — um membro é adicionado e removido por uma ação; ou dynamic — a associação é derivada de uma regra, e quem satisfaz o predicado é membro. Um Group é um dos dois, nunca ambos
rulepara um Group dinâmico: o predicado, na mesma linguagem de predicados dos predicados de acesso e das guardas de transição. A plataforma o recomputa; ninguém fica consultando
capacitye o comportamento ao alcançá-la
entry ruleum predicado que pode rejeitar a entrada, separado de um Hook que também pode rejeitá-la
lifecycle behaviourna primeira entrada — created on first entry ou created in advance; e na última saída — closed on last exit ou kept while empty. Declarado, nunca inferido de observação
lifetimeopcional: quando expira, o Group fecha com um Event

O que vale para todo Group.

SempreO que é
memberé um Actor, nunca uma Entity: um conjunto de Entities é uma seleção sobre Data & Subscriptions. Um Group é um ouvinte em massa
statescreated → active → closed, e closed é terminal. Uma instância de Group tem máquina; o tipo não a declara
event targetemita nele e os membros dele recebem; é isso que faz da entrega em massa um sinal em vez de um laço
a group callsão N chamadas, não uma: o Group fornece o endereçamento e cada desfecho chega ligado ao membro de quem veio. RPC exige exatamente um handler lógico, então um broadcast que espera muitas respostas são N chamadas, não uma
partial outcomenunca se lê como completo: um membro que falhou, expirou ou recusou é a própria resposta dele carregando o Problem dele ao lado das que responderam; um sucesso parcial nunca é devolvido como total
recipientsnunca são enumerados por quem envia: a associação decide, então quem envia não precisa saber a composição da audiência
intra-group rolesnão existem: um "dono do Group" é um Actor segurando um direito (Access & Roles), não uma patente guardada na lista de membros
first entry and last exitsão distinguíveis das entradas e saídas do meio — que é do que pendem o comportamento declarado de ciclo de vida e a inicialização de rodada
recomputationcarrega o Delta: o Event de composição de um Group declarado por regra diz quem entrou e quem saiu, nunca a lista inteira. A lista inteira é uma leitura, então um assinante que só quer a mudança nunca paga pela lista
join and leavesão idempotentes: um cliente reconectando repete o join, recebe a mesma associação e nenhum erro — código de cliente nunca precisa distinguir "já estou dentro" de "não posso entrar"
the interfaceé de um Group concreto, não só do tipo: você endereça este squad
the primitivefica vazio: regras de entrada, inicialização de rodada no primeiro membro, interceptação de Events — esses são os módulos construídos sobre ele. Uma Room é um Group com regras de vaga, uma conversa de Messaging um Group com regras de entrega, uma fila de Matchmaking um Group que o matcher drena, uma lista de envio um Group sem regra alguma

Erros

  • Uma regra que diz não e um Hook que diz não são respostas diferentes. Uma regra de entrada falsa se lê como "entrada impossível"; a rejeição de um Hook carrega a razão e o código próprios dele. Um Hook de entrada que não pode ser alcançado recusa a entrada — a verificação falha fechada em vez de acenar para o Actor passar.
  • Associação dinâmica recusa edições manuais. Add ou Remove num Group declarado por regra é recusa de validação: o predicado é a única coisa que move aquela lista, e a plataforma o recomputa quando os dados por trás dele mudam.
  • Quatro direitos, nenhum implicando outro — entrar, administrar a associação, publicar no Group, ler a composição (Access & Roles). Um Actor a quem falte um recebe recusa, nunca um no-op silencioso; um Group escondido dele por um predicado de visibilidade responde not found; e um membro sempre enxerga a própria associação mesmo quando a composição lhe é fechada.

Limites

  • Cheio é conflito, não questão de direito. Na capacidade + 1 a entrada é rejeitada como conflito — o Actor era permitido, a vaga não — e a mesma chamada tem êxito assim que uma vaga abre. A capacidade em si está no tipo (Capacity = 4 acima); quantos Groups um projeto e um único Actor podem segurar é definido com o capítulo de limites da plataforma.
  • Uma chamada de grupo grande demais é recusada inteira, antes de qualquer envio — um fan-out nunca é entregue pela metade, então nenhum chamador precisa detectar esse caso. O teto de tamanho chega com o capítulo de limites da plataforma.

Fluxo do usuário

Uma party se forma, uma convocação alcança todo membro, um RPC fan-out traz uma resposta por membro, e o squad entra na fila como unidade.

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Extensibility

Todo cenário da plataforma é uma cadeia de funções registradas. Substitua um elo ou envolva-o. É isso que "plataforma customizável" significa concretamente, e é isso que está no lugar do código aberto: você substitui os passos da própria plataforma pelos seus, então não precisa do nosso fonte.

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Um passo da plataforma precisa rodar a sua lógica — declare a substituição de um elo nomeado com [Override(…)].
  • Você precisa de verificações ou efeitos colaterais em torno de um passo — middleware Before/After ordenado que pode vetar ou notificar.
  • Código precisa rodar por agendamento, por Event ou por webhook — os triggers lhe entregam um contexto tipado e já analisado.
  • Você precisa saber o que de fato vai rodar antes do deploy — simule uma cadeia e leia a ordem resolvida.
  • Pule quando a regra diz respeito às escritas de uma Entity — um Hook de Data & Subscriptions é a forma mais leve.

Quem faz o quê

ActorNesta página
backend-servicesobrescreve elos, envolve passos com middleware, escreve handlers de trigger
operatorinspeciona cadeias, define ordem, lê segredos, simula a resolução

De relance

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.

O modelo

A que a plataforma lhe dá para se prender.

TermoO que é
registered functionum passo sobrescrevível da plataforma — "criar perfil", "resolver preço"
scenarioa cadeia ordenada que um fluxo da plataforma executa: entrada, join, compra
overridabilityse um elo pode ser substituído, apenas envolvido, ou é fixo
middlewareum handler pré/pós ordenado em torno de um elo
triggero que inicia o seu código: um Event, um agendamento, um webhook
secretum valor que o seu handler pode ler
invocationuma execução, com o rastro dela
A scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

O que um Hook declara.

DeclaraO que é
positiono passo nomeado ao qual ele se prende
kindgatekeeper — uma verificação de admissão ou uma validação, e ele falha fechado, então o passo não roda quando o próprio Hook quebra; ou observer — um log, uma notificação, um contador, e ele falha aberto: o passo roda, e a falha ainda assim é reportada em vez de engolida. Não há padrão
momentbefore — antes da validação, recebendo o payload tipado, e pode alterar ou rejeitar; ou after — depois que o passo foi efetivado, recebendo pedido e resultado, só efeitos colaterais, e nunca pode fazer a operação falhar nem alterar a resposta
effecto que o Hook faz, e não apenas onde ele fica. É isso que transforma "qual destes roda primeiro" de uma briga por números numa afirmação sobre o trabalho, e é por isso que a ordem sobrevive a alguém acrescentar um Hook ao lado do seu
version and conditionum handler que se aplica a um ambiente ou a uma audiência é uma versão declarada daquele Hook em vez de um ramo dentro do corpo dele, e é isso que o botão do painel alterna

Três jeitos de prender código.

FormaUse para
atributo [Before(Step)] / [After(Step)]uma regra num passo nomeado — a maioria dos Hooks
atributo [Override(Link)]substituir a implementação de um elo por completo
Extend.Scenario("…").Before("link", fn, order: n)envolver um elo dentro de uma cadeia, quando a ordem em relação a outro middleware importa
SempreO que é
all threesão implantados com playserv push
the two attribute shapessão o que o painel desenha, porque a Declaration leva o nome do passo ou do elo para o modelo enviado
the middleware formleva uma ordem em vez disso, que é o que uma cadeia precisa
assigning at startup(Scenario.OnX = fn) continua disponível para um handler que não precisa aparecer na árvore administrativa
replacing one linkdeixa os elos de cada lado intocados, e nenhum deles sabe qual implementação respondeu — o passo da própria plataforma ou o seu

Para onde vai uma chamada, e o que vale para toda rota.

SempreO que é
four directionsuma função de nuvem · o backend externo do consumidor · o servidor de jogo · outra declarada
the routeré dirigido por mensagem/sinal; request-response é um adaptador sobre ele em vez da natureza dele
matchingé pelo nome declarado da operação ou do sinal e por mais nada: não por forma de payload, não por quem chama, não por carga
a name registered twiceé defeito da Declaration, recusado quando o conjunto é declarado em vez de resolvido no momento da chamada
an unregistered nameresponde not found, em vez de ser descartado em silêncio
the directionnão faz parte do contrato da operação: mover um handler entre direções não é mudança quebrante
"the game server"é definido pelo que ele é, não por quem o hospeda — a nossa frota e a hospedagem própria de um estúdio são uma direção, e a Declaration não carrega marcador de quem é dono da infraestrutura
game-server RPCsregistram-se no mesmo roteador: declarar um é registrá-lo, e não há um segundo jeito
orderingroda o middleware de cima para baixo, e onde um passo tem mais de uma implementação o roteador escolhe da esquerda para a direita por condição, com a versão marcada como padrão respondendo quando nada casou

O que é uma restrição entre Hooks, e quando ela é verificada.

O que é
a named constraintum ponto de extensão pode nomear os efeitos que restringe — uma verificação de anticheat precisa preceder uma colocação, um recibo exige uma cobrança neste ponto — e não restringir mais nada
an unnamed effecté irrestrito, não recusado: um consumidor fazendo algo que ninguém previu é para o que o mecanismo existe, e um vocabulário fechado transformaria isso numa rejeição no momento do registro
a violationé defeito de configuração, e a recusa nomeia os dois Hooks e a restrição que eles quebraram — não um aviso, e não uma reordenação silenciosa
when it is checkedem todo ato que possa mudar o que roda num ponto: registrar, implantar, mudar o arranjo. Assim, um arranjo que chega à execução já foi admitido
never re-checked at run timeisso seria uma segunda resposta a uma pergunta já resolvida, feita no único momento em que nada pode ser feito a respeito
SempreO que é
handlerssão tipados na entrada e na saída: nada de dynamic, nada de sacolas de contexto. O handle da plataforma é ambiente, e o contexto da invocação — quem chamou, trigger, rastro — chega já analisado
what an engine build seesos Events que o cenário emite depois, porque uma sobrescrita ou um middleware roda na plataforma e um runtime de engine não é lugar para hospedá-los. É isso que as abas @na dos exemplos desta página querem dizer com assinar os Events resultantes
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.

Pegue, mude, publique. "Código aberto proprietário" é um fluxo de trabalho, não um slogan — a lógica da própria plataforma são funções que você pode puxar, editar e reimplantar:

playserv functions pull matchmaking.match     # a implementação da plataforma, como fonte
# edite: alargue a janela de skill para o evento de fim de semana
playserv push                                 # a sua versão registra; o padrão fica como reserva

A customização aqui corre sobre um eixo, e ele é lógica: os overrides, o middleware e as versões de que esta página trata.

O outro eixo não existe. Você não pode acrescentar os seus campos a uma Entity da plataforma. Um jogador, uma Room, um registro de Leaderboard e um pedido são estado de sistema com máquinas próprias, não o começo do seu modelo de dados. Os seus dados são a sua própria Entity, declarada em Schema as Code e amarrada ao estado da plataforma por um predicado — o dono é este jogador, o escopo é esta Room — que é também o que os mantém seus quando o modelo da plataforma se move.

Erros

  • Um nome que não casa com handler registrado algum responde not found — uma chamada nunca é descartada em silêncio porque ninguém estava ouvindo.
  • Um nome registrado duas vezes, e um conjunto com duas implementações reivindicando a mesma condição, são recusados quando o conjunto é declarado — no deploy, e não resolvidos por sorteio no momento da chamada.
  • A recusa de um Hook carrega o código e a razão do próprio Hook, para que "uma regra do jogo disse não" nunca chegue parecendo falha de transporte.
  • Um Hook que falha se comporta pelo kind declarado dele — fail-open ou fail-closed — e qual dos dois foi declarado em vez de inferido do que aconteceu.
  • Um Hook não pode mudar o que já foi reivindicado: nem o dono, nem o target, nem o Leaderboard ou a conversa a que uma chamada foi endereçada. Ele corrige entradas e devolve um veredito.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • O prazo de execução de um Hook — passado ele, o comportamento de falha que o kind dele declara.
  • Hooks numa posição e implementações de um método — registrar mais um é recusado.
  • A profundidade de aninhamento de "um Hook invoca uma operação que tem Hooks" — uma recusa declarada, nunca esgotamento de recursos.
  • O tamanho do contexto passado a um Hook — truncar é proibido, então o registro é recusado em vez de um handler receber meio contexto.

Fluxo do usuário

Uma compra, do clique do jogador pela cadeia customizada até o ping no squad. fraud-check é o handler do próprio estúdio em Before("grant"), não um módulo da plataforma; Catalog & Commerce desenha a mesma compra pelo lado dela.

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

Uma cadeia com sobrescritas e middleware pode ser resolvida e lida antes que algo rode. A ordem resolvida é inspecionável no painel e a partir de código.

Lições e receitas: Leaderboard em Tanks usa os Hooks deste módulo; o torneio diário atravessa este módulo.

Your game's model

Schema as Code

Declare o modelo em código, envie-o, receba tipos de volta. O caminho do desenvolvedor até o schema: o painel administrativo e o código escrevem o mesmo modelo, e a geração de código fecha o ciclo para toda engine.

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

Quando usar

  • O seu modelo de dados deve morar em código e passar por review como código — declarar, schema diff, schema push.
  • Os tipos de engine nunca podem divergir do modelo implantado — schema codegen regera o Unreal C++ e o Unity C#.
  • Uma mudança quebrante precisa ser legível e cancelável antes de rodar — propose → plan → apply.
  • Você reaproveita um pacote (Stat, Interactable) entre projetos — declare-o uma vez como preset.
  • Pule quando um operador só ajusta valores no painel administrativo — a mudança de modelo ainda assim volta como diff para o código.

Quem faz o quê

ActorNesta página
schema-authordeclara Entities/parts/enums em código, faz diff e envia
operatorrevisa a visão geral do painel, propõe e aplica migrações
cio pipeline de build, rodando sob uma chave backend-service: envia num merge e regera os tipos de engine em seguida

De relance

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # Declarations locais contra o schema implantado
playserv schema push       # com pré-condição de Revision — sem sobrescritas às cegas
playserv schema codegen    # regerar os tipos Unreal C++ / Unity C#

O push é um passo explícito. Nada é enviado quando você salva um arquivo: uma Declaration alcança o modelo implantado apenas quando playserv push (ou schema push) roda, da sua máquina ou do CI, e ele carrega a Revision contra a qual foi comparado. Uma edição no painel fica visível para você como qualquer outra divergência — schema diff a mostra contra as suas Declarations. Vê-la é automático; movê-la em qualquer direção é um comando que você roda de propósito.

O modelo

O que uma Declaration carrega.

CarregaO que é
keyo nome estável pelo qual ela é endereçada. Renomear em código é renomear, não apagar e criar
kinduma Entity, um part ou um enum, declarado em código
ownership modeseed — o código cria o registro se ele não existe e um push repetido deixa os valores em paz, então o console administrativo passa a ser dono deles; ou managed — o código é sempre dono, todo push traz os valores ao que foi declarado, e edições do console administrativo são recusadas em vez de aplicadas e perdidas no push seguinte
presetopcionalmente: um pacote reaproveitável — um preset de Entity como stats ou world-objects — declarado uma vez e aplicado como um tipo. Ele não introduz um novo tipo de Declaration, e um preset que precisasse de um seria uma lacuna no contrato em vez de um preset maior

O que um push promete.

SempreO que é
matchingé por chave, nunca por símbolo: um push repetido depois de renomear o símbolo deixa um registro, não dois
idempotencydecorre disso — um push repetido não é um segundo registro
the reportdiz exatamente o que vai mudar antes de aplicar, e o que sobrescreveu depois
originé distinguível: um registro criado por um push a partir de código se distingue de um criado em outro lugar
the revisionviaja junto, e um push chega inteiro ou não chega

O que a geração de código promete.

SempreO que é
regenerationacontece depois de cada push, e os tipos gerados nunca são editados à mão: regerar e depois comparar não produz mudança
namingsegue a Declaration onde quer que ela tenha sido escrita — o campo Rarity de Item vira UPSItem::Rarity em Unreal e Item.Rarity em Unity
the two directionsnão se bifurcam: o que é declarado em código aparece no painel, e o que um operador escreve no painel compara limpo com o código — que é o que faz de schema diff uma resposta completa em vez de metade de uma

O que uma migração promete.

SempreO que é
when one is requireduma mudança de Declaration persistente que reescreve valores existentes, e uma mudança persistente quebrante não pode ser publicada sem uma
what it declaresuma versão, uma prévia, uma aplicação ordenada, um rollback em caso de falha e um desfecho de conclusão observável
coexistenceenquanto duas versões de dados estão vivas, leituras e escritas declaram quais versões aceitam — o runtime nunca infere compatibilidade a partir de nomes de campo

Erros

  • Quem chama sem o papel recebe forbidden e o modelo implantado fica intocado: uma recusa nunca é um push parcial. Essa é uma recusa diferente de uma Revision obsoleta, que é precondition_failed e quer dizer que o diff foi calculado contra um schema que se moveu desde então — refaça o diff e envie de novo.
  • Um valor fora de um limite declarado é recusado na escrita, nunca cortado, e uma string além do comprimento declarado também. Cortar produz um valor que é válido e errado, e o custo cai sobre o suporte em vez de sobre quem chamou: uma recusa custa uma ida e volta.
  • Uma sequência UTF-8 inválida é rejeitada na escrita em vez de reparada.
  • Uma mudança persistente quebrante sem migração declarada não pode ser publicada de forma alguma.

Limites

Tetos em forma de Declaration são verificados no momento da declaração — no deploy ou na publicação — e não no primeiro uso, sempre que o sintoma em runtime não pareceria uma recusa. Essa é a regra que o capítulo de limites da plataforma enuncia, e é por isso que um schema que é publicado é um schema que já cabe. Os números em si chegam com aquele capítulo.

Fluxo do usuário

Um campo novo, da Declaration em código aos tipos de engine regerados.

Your game's model

Entity

O módulo em que todo o resto se apoia. Uma Entity é uma declaração de schema crescida com aspectos vivos: dados 0..*, estados 0..*, RPC 0..*, Events 0..*, Hooks e histórico de mudanças. Mapas ligam obstáculos a Entities, Collision liga um aspecto de transform, Stats é um preset, World Objects é um preset mais uma máquina de estados.

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity monta na raiz, então room.Entity<Door>(id) e playserv.Entities<KeyDef>() ficam diretamente na raiz em vez de atrás de um espaço de nomes. Ela se constrói sobre três Primitives — Events, RPC e Data & Subscriptions — e sobre mais nada. Collision, Locomotion e Prediction & Lag Comp ficam acima dela: cada um se liga a um aspecto, não à Entity inteira, que é por que um contato pode disparar uma transição sem que o módulo de Collision saiba nada sobre permissões.

Quando usar

  • Um objeto do mundo precisa de comportamento, não só de campos — máquinas de estado, RPC com permissão e Events numa Declaration.
  • Portas, armadilhas, itens apanháveis: transições precisam disparar a partir de Events de cliente, contatos de Collision ou limiares de Stat sem código de Room.
  • Você quer objetos de jogo como criações de uma linha — aplique ou derive presets como world-objects.
  • Uma disputa precisa do estado exato do mundo no instante do tiro — leia uma instância num sim_time passado, dentro da janela declarada.
  • Pule quando a coisa não tem identidade — um valor que só existe dentro de outra coisa, como o texto da placa de uma porta, é um campo num aspecto, não uma Entity própria. Tudo o que é endereçado é uma Entity: Data & Subscriptions é a mecânica por baixo, e caminho de tabela algum a contorna.

Quem faz o quê

ActorNesta página
schema-authordeclara Entities, aspectos, máquinas de estado, presets
every actorconsulta, assina, chama RPC de Entity, lê estado

De relance

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

A unidade declarada é o aspecto, não o campo: motion carrega a própria cadência e a própria máscara, info carrega outras, e um campo pertence a exatamente um deles. É isso que permite a um preset anexar um grupo inteiro de uma vez, e o que permite a Collision ligar-se ao único aspecto que carrega um transform sem enxergar mais nada na Entity.

Três regras governam a máquina daquele bloco:

  • Um nome com ponto aninha um nível. open.blocked se associa a open por si só, então a transição close_requested declarada em open vale dentro dele sem ser repetida. Enquanto a máquina está em open.blocked ela está em open — uma checagem de estado para open é verdadeira, e OnEntered("open") disparou na entrada e não dispara de novo para o subestado.
  • Um temporizador declarado num estado corre só enquanto aquele estado é o atual. Sair de opening descarta o AfterSeconds dele, e entrar de novo começa um novo.
  • Uma guarda é um predicado declarado sobre os campos da própria Entity, na mesma linguagem de um predicado de linha de acesso. Lógica que precisa de código é um Hook, não uma guarda.

Caminhos de campo são a grafia do modelo enviado. Predicados e caminhos de consulta nomeiam campos como a Declaration os enviou — motion.jammed, gate.state, info.name — não importa como cada binding os escreva localmente.

Quem pode chamar o RPC. Um RPC de Entity nomeia o direito de que precisa do mesmo jeito que toda operation: um atom de direito (entity × execute) mais um predicado de linha dizendo quais instâncias ele cobre (Access & Roles é dono dos dois).

Na DeclarationO que significa
caller in entity.roomqualquer Actor na Room em que a porta está, rodando o build que for. Proximidade não faz parte disso: quão perto você precisa estar para receber os deltas da porta é uma regra de Visibility sobre a política de sync do aspect — banda, não permissão, e alargar uma vista nunca alarga um direito
Actora identidade de quem chama, o mesmo objeto que whoami devolve
caller.Inventoryo handle de Inventory para aquele jogador, disponível onde quer que esse módulo esteja montado

playserv push é o que torna uma Declaration real. Schema as Code é dono do passo: ele compara as suas Declarations com o modelo implantado, carrega a Revision contra a qual foi comparado, e recusa em vez de sobrescrever se o schema implantado se moveu. Um novo push que quebraria instâncias já vivas passa por propose → plan → apply, então o plano é legível antes que algo mude.

No cliente, a Entity é a API:

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

O sinal locked é o Event da própria porta: o target dele é o da Declaration — esta instância — então todos que assinam a porta ouvem, e lista de destinatários alguma viaja com o envio.

O modelo

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

Um tanque, uma porta, uma barra de Stat e uma missão são todas Entities. Elas diferem em quais aspectos carregam e em mais nada, que é o que permite a todo outro módulo se construir sobre este.

O que uma Entity declara.

DeclaraO que é
aspectum grupo nomeado de campos declarado inteiro, com política de sincronização e máscara de acesso próprias. Uma Entity carrega vários, e nenhum campo está em dois
state machineestados aninháveis um nível, transições e guardas; vários por Entity
triggero que dispara uma transição — as quatro fontes estão abaixo
entity RPCum verbo saindo da Entity, declarado dentro da visão com o átomo de direito de que precisa
entity eventum sinal que a Entity emite, entregue a quem assina aquela instância
hookantes e depois, em operações de dados e em transições, implantados como funções de nuvem. Extensibility declara a ordem, a forma do veredito e o que uma falha faz
history trackse a visão mantém a janela instantânea
refum vínculo com outra Entity que guarda o id dela e nunca o key, então renomear uma chave nunca quebra um vínculo. Include o traz junto com a página

O que dispara uma transição, e nenhuma das quatro é o seu código rodando numa Room.

FonteComo dispara
client event or RPCqualquer um declarado — RequestOpen acima dispara open_requested
collisionum contato ou a entrada num volume de gatilho — armadilhas, placas de pressão — pelo aspecto ao qual Collision se liga
data thresholddeclarado num Stat, 0 HP → death, garantido por ordem de Hooks em vez de por código numa Room
timeAfterSeconds num estado é um trigger declarado, não uma corrotina: ele corre pelo relógio de simulação da Room, avança com sim_time, para enquanto a Room não simula, e apagar a instância encerra as máquinas dela e os timers pendentes junto

O que uma seleção pode fazer.

EixoO que é admissível
filter e sortapenas campos declarados — não existe handle de tabela, e uma seleção é endereçada por Entity, no escopo de uma Room ou do projeto
includeum ref declarado, trazido junto com a página
pagingpor cursor opaco: não é offset, não é id de linha, e o significado dele não sobrevive a uma mudança de versão. Devolva-o, nunca o analise
accesspredicados se aplicam antes da paginação, então uma página nunca carrega buracos onde estariam linhas escondidas
liveassinar uma seleção a mantém viva, com membros entrando e saindo conforme os dados deles mudam
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

O que vale para toda Entity.

SempreO que é
a selectioné um conjunto de Entities, não um Group: os membros de um Group são Actors e existem para que um sinal alcance todos eles, enquanto uma seleção é uma leitura que por acaso continua viva
a transition requestcontinua sendo um pedido: as guardas da máquina rodam, o predicado de linha roda, e uma transição que a máquina não declara é recusada com invalid_state_transition em vez de ignorada em silêncio
pushing past a guardé outra operação com outro átomo — entity × administer, que chave de cliente alguma segura por padrão
historyé só a janela instantânea: estados recentes indexados por sim_time, limitados por uma profundidade declarada, e uma leitura fora dela é recusada em vez de respondida com o valor mais próximo. Histórico ramificado — linhas alternativas, desfazer, reproduzir uma partida inteira — está fora de escopo, porque teria de prometer valores de ponto flutuante reprodutíveis e as regras de tipo não prometem
the boundary of a changeé uma Entity, e é aí que "tudo ou nada" para. Duas Entities mudadas por um mesmo chamador — debitar uma carteira, acrescentar o item — podem ser observadas meio aplicadas. Então um par que precisa aparecer junto não são duas Entities: mantenha os dois valores numa instância e a fronteira faz o trabalho. Recorrer a um Hook para "torná-lo atômico" não faz, porque o Hook roda em torno de uma mudança em vez de atravessar duas
a declared method with no implementationé um estado acabado, não um meio configurado. Chamá-lo responde com um veredito carregando a razão legível por máquina "sem implementação" — não uma recusa, e não um sucesso com resultado vazio. Uma recusa diria que a chamada não devia ter sido feita; aqui devia, e a única coisa que não aconteceu foi a decisão
a name never declaredé um desfecho diferente de um nome declarado sem implementação: o primeiro é recusa de validação, o segundo é veredito, e o código os distingue
an unimplemented callnão some — que alguém o invocou é observável para o estúdio. Que forma essa observação toma deliberadamente não faz parte do contrato, então construa sobre o fato de ser observável, não sobre uma linha de log
creating an instancecarrega o átomo entity × write: uma função de nuvem, um servidor dedicado e um master-client o seguram por padrão, e um cliente comum só onde um papel o conceder — em todo binding, não apenas em Unreal

Presets. Um preset é um pacote nomeado de aspectos, máquinas, Hooks e limites aplicado a uma visão. Ele não acrescenta conceito algum — tudo o que um preset traz você poderia declarar à mão, que é por que um preset que precisasse de um novo tipo de Declaration é uma lacuna no modelo em vez de um preset maior. Cinco vêm prontos: stats, abilities, projectiles, drops, world-objects, e Entity Presets declara cada um por inteiro. Um estúdio deriva os próprios a partir deles, em código ou no painel — Crate é world-objects mais stats:

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Erros

  • Uma instância que não existe, e uma que um predicado esconde, respondem ambas not found — para que uma recusa nunca conte a quem chamou que algo existe mas não é dele.
  • Um campo não declarado, aninhados incluídos, e um campo obrigatório sem valor, são recusas de validação nomeando o campo.
  • Uma transição que a máquina não declara é recusada como invalid_state_transition, nunca ignorada em silêncio. Empurrar uma máquina para além das guardas dela é uma operação diferente com outro átomo — administer, que chave de cliente alguma segura por padrão.
  • Uma divergência de versão é falha de pré-condição: releia e decida de novo.
  • Uma chave já tomada é conflito.
  • Uma escrita em nome de um jogador que não nomeia o jogador é recusa de validação em vez de uma escrita atribuída a ninguém.
  • Sem permissão responde forbidden, com leitura e escrita distinguidas.
  • Uma leitura de histórico fora da janela instantânea é recusada em vez de respondida com o valor mais próximo — "não há dado para aquele Tick" e "aqui está aproximadamente o valor" são fatos diferentes.
  • Uma instância armazenada além do limite de tamanho é conflito nomeando o campo culpado e o tamanho medido; o teto é alcançado por acumulação, então a aproximação dele é observável antes da escrita que falha.

Limites

Todo limite é declarado junto com o que acontece na fronteira dele. Os números são por projeto e definidos por projeto; o comportamento abaixo já está fixo.

LimiteNa fronteira
aspectos por visão · máquinas por visão · profundidade de aninhamento dentro de um aspectoa Declaration é rejeitada no playserv push, nunca truncada em silêncio
tamanho da instância armazenadaa escrita é recusada como conflito, nomeando o campo e o tamanho medido; aproximar-se do limite é observável antes da recusa
tamanho da página de seleçãoa página é cortada no teto e "há mais" continua verdadeiro — você nunca recebe uma página curta que pareça final
janela de histórico instantâneouma leitura fora da janela é recusada, não respondida com o valor mais próximo
taxa de mudanças numa instânciauma recusa por limite de taxa carregando quanto esperar

Fluxo do usuário

Uma porta, da Declaration ao som que o jogador ouve.

Your game's model

Herança e composição

Os módulos se constroem uns sobre os outros, e nada disso é herança de classe. Não há um módulo base do qual derivar nem hierarquia para estender — os módulos formam um grafo. Esta página é o que "herança" honestamente quer dizer aqui, e os seis mecanismos que fazem o trabalho no lugar dela.

O que herança quer dizer aqui

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

A palavra cobre quatro mecanismos diferentes, e vale a pena separá-los pelo nome.

  • Os RPC de uma Entity são parte da Entity. Eles não existem em lugar nenhum além dela — nem num pai qualquer, nem num registro compartilhado. Se um método pertence a uma porta, ele está na porta. Veja Entity.
  • Um preset é um pacote nomeado, não uma classe base. Stats, Abilities, Projectiles, geradores de drop e World Objects são presets de entity — pacotes de aspectos que uma visão de Entity aplica, que é por que moram numa página, Entity Presets, em vez de em cinco módulos. Aplicar um preset acrescenta aspectos; não põe o seu tipo debaixo de nada.
  • Sobrescrever um passo da plataforma é um atributo na sua substituição. Você não herda da nossa; você declara a sua, e as versões são escolhidas por condição com o padrão da plataforma como reserva. Veja Extensibility.
  • Um módulo toma outro emprestado por um decorador que estreita ou enriquece a interface tomada, com a implementação trocável por trás dela. O caso trabalhado é um chat dentro de uma Room, em Groups.

E o que ela não é: não há hierarquia de classes de módulos, porque uma árvore só admite ramos e as funcionalidades de verdade os atravessam. Matchmaking reserva vagas em Rooms; o drop coloca itens através do Map; um Leaderboard é alimentado por um Hook no fechamento de uma Room. Isso é um grafo, e é deliberado.

Os seis mecanismos

Cada um é declarado com um atributo ao lado daquilo que ele compõe — a mesma regra declarativa que governa tudo o mais no SDK.

Pontos de montagem, como num sistema de arquivos. Um módulo monta na raiz — compondo várias interfaces numa superfície — ou dentro de um espaço de nomes. Um segundo módulo reivindicando um ponto de montagem ocupado é rejeitado no momento da montagem, nunca na primeira chamada. O mecanismo está em Por baixo do capô.

Visibilidade léxica. A visibilidade de nomes segue o aninhamento: uma declaração global é visível dentro de um módulo, uma local nunca vaza para cima. O que um módulo emite é outra questão e é declarado no contrato próprio dele — um módulo conhece apenas os Events que declarou, ou que foram registrados nele.

Encapsulamento como contrato. Um módulo nunca sabe quem o chama nem por quê. O que ele expõe e o que ele emite é toda a história pública dele, e nada em quem chama muda o comportamento dele exceto a concessão de quem chama.

Reúso por decorador e inversão de controle. Um módulo se refere a outro por um decorador em vez de enfiar a mão dentro dele, e a implementação por trás da interface é trocável. É este o mecanismo que lhe permite substituir um módulo nosso pelo seu sem que os módulos que dependem dele percebam.

Declarations fazem a API crescer. Declare um Event num Group e group.Send.ChatMessage(…) aparece com o contrato dele; declare os dados members e um getter tipado aparece. A Declaration é a entrada da geração de código — que é também por que o que você versiona é a Declaration, não o código gerado.

Três eixos de endereçamento a partir de um módulo. Todas as instâncias, uma instância e o administrador de uma instância são três APIs distintos, não um API com uma flag. Enunciado por inteiro em Groups.

Argumentos implícitos, e por que não são mágica

Dentro de uma Entity você nunca passa a Entity. O receptor, quem chama e o contexto ambiente se ligam automaticamente, porque os três já estão determinados por onde a chamada foi feita e por quem a fez — passá-los seria pedir que você repetisse algo que a plataforma já sabe, e lhe dar a chance de dizê-lo errado. O mecanismo está em RPC.

Your game's model

Entity Presets

Um preset é um pacote nomeado de aspectos de Entity — dados, estados, RPC, Events, Hooks — empacotado para um caso de jogo. Você aplica um preset, ajusta os números dele, ou deriva o seu. Aplicar um acrescenta aspectos ao seu tipo; não põe o seu tipo debaixo de nada — um preset não é um módulo e não tem nada de próprio do que herdar. Stats, Abilities, Projectiles, tabelas de drop e World Objects são cinco presets, não cinco subsistemas: a mesma Declaration, a mesma sincronização, a mesma ordem de Hooks.

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

Quando usar

  • Uma coisa no seu jogo carrega números que se limitam, se regeneram e disparam uma transição nos limites deles.
  • Uma ação precisa de custo, cooldown, fases e efeitos, alcançáveis por um verbo de cliente.
  • Algo sai voando, e o acerto dele precisa ser julgado com justiça para um atirador com lag.
  • O loot precisa vir de probabilidades ponderadas que se reproduzem exatamente quando um jogador contesta um drop.
  • O mapa tem mobília — portas, botões, armadilhas, destrutíveis — com estados que precisam sobreviver a uma entrada no meio da rodada.
  • Pule presets quando uma Entity é apenas dado sincronizado. Declare os campos e pare por aí.

Quem faz o quê

ActorNesta página
schema-authordeclara Stats, Abilities, Projectiles, tabelas de drop, World Objects
room-ownerajusta números de preset, rola tabelas de drop, cria World Objects
playerusa abilities, atira, apanha loot, interage com objetos

De relance

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

O modelo

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

Um preset não introduz noção nova alguma. Tudo o que ele acrescenta é exprimível pelos meios que Entity já dá — aspectos, máquinas, Hooks, persistência. Um preset que precisasse de um novo tipo de Declaration seria uma lacuna no contrato, não uma razão para aumentar o preset. Esse é todo o teste de se algo pertence aqui.

O que cada preset dá, e onde é ajustado.

PresetO que o contrato lhe dáOnde é ajustado
Statsum aspecto de características numéricas com limites, regeneração e modificadores, mais um Hook ao alcançar um limiar — 0 HP vira uma transição de máquina em vez de um if no seu códigoa Declaration do campo; os números continuam editáveis ao vivo no painel
Abilitiesum aspecto com um conjunto de abilities, uma máquina de fases de aplicação, e custo e cooldowna Declaration da ability
Projectilesum tipo com persistência runtime, um aspecto de balística e um Event de acertoa Declaration do projétil — trocar um atributo muda o modelo de voo
Dropsum aspecto de tabela de drop com pesos, e um Hook depois da morteas entradas da tabela e os pesos delas
World Objectsuma máquina de estados de objeto interativo, e um aspecto com a condição de interaçãoa Declaration do preset, ou por instância na criação
Inventoryum tipo com dono e um ref para um item de catálogo, um aspecto de pilha com incremento, e um teto por dono com transbordo declaradoa Declaration do tipo

A tabela é uma linha por preset porque uma linha é o que difere entre eles. O que eles têm em comum está abaixo, e Inventory é o único que também tem página própria.

O que vale para todo preset.

SempreO que é
where it sitsnuma Entity, como aspectos: os dados dele sincronizam como quaisquer outros, os estados dele são estados de Entity, os RPC dele são RPC de Entity, e os Hooks dele rodam na ordem de Hooks de Entity
tuningé configuração ao vivo em vez de novo deploy, que é por que o painel mostra um limite de Stat, um cooldown e um peso de drop numa árvore só
declaring oneé um ato de schema, não uma chamada de jogo — que é por que a recusa dele é de outro tipo que as recusas com que um jogador se depara, e ambas estão em Erros abaixo
deriving your owné composição, não herança: Crate é world-objects mais stats, e a coisa derivada continua sendo aspectos numa Entity

Erros

  • Declarar um Stat, uma Ability, um Projectile, uma tabela de drop ou um World Object é um ato de schema — fn ou adm. Um jogador ou uma chave de cliente que tente um recebe forbidden, e nada é declarado nem meio declarado. Essa é uma recusa diferente daquelas com que um jogador se depara dentro de uma chamada que ele podia fazer — em cooldown, não consegue pagar, um item:key.bronze ausente — cada uma das quais carrega o código próprio dela.

O resto das recusas de um preset são de Entity — um preset não introduz noções, então também não introduz recusas, e repeti-las aqui daria ao leitor dois lugares para conferir uma resposta. Duas coisas são específicas dos presets em si:

  • Um preset preenchido pela metade é falha de validação no deploy. Um preset carrega um conjunto coerente: meia Declaration é recusada antes de ser publicada em vez de se comportar de forma estranha numa partida.
  • Um preset não pode ser marcado com uma propriedade que a mecânica dele contradiz — um aspecto para o qual o cliente não tem regras não pode ser declarado previsível, e isso também é pego no deploy.

Limites

Os tetos são de Entity — aspectos por tipo, máquinas por tipo, o tamanho da instância armazenada, a taxa de mudanças numa instância. O único que um preset declara por conta própria é o teto por dono que um preset com dono carrega, com um de três comportamentos de fronteira e sem padrão: refuse · redirect para um balde de dono declarado · discard with event. Os números chegam com o capítulo de limites da plataforma.

Fluxo do usuário

Um projétil, do puxar do gatilho à caixa aos pés do atirador. Quatro presets participam — ability, projectile, stat e drop-table — e nenhum deles é um módulo que você monta.

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Rooms

Uma Room é uma sessão de jogo; a plataforma não se importa com o que a hospeda. Uma abstração cobre um servidor dedicado por partida, um mapa grande e compartilhado dividido em camadas lógicas, uma Room hospedada por master-client e um minijogo hospedado no backend. O interior da Room é nosso; você conduz uma Room de fora.

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • O seu jogo tem sessões — partidas, lobbies, masmorras, corridas — e algo precisa ser dono do ciclo de vida, da associação e das reconexões delas.
  • Você hospeda em servidores dedicados, no master-client de um jogador ou no próprio backend, e precisa rotear jogadores até lá.
  • Um mapa compartilhado precisa rodar muitas sessões lógicas — camadas, no escopo de Visibility.
  • Jogadores entram no meio da sessão e precisam ver a verdade atual — o estado da Room na chegada, depois tráfego ao vivo.
  • Uma conexão caída não pode custar a vaga — a janela de tolerância do template (45s em battle) retoma a mesma associação.
  • Pule quando uma funcionalidade é puramente requisição/resposta sobre registros — Data & Subscriptions já cobre isso.

Quem faz o quê

ActorNesta página
room-ownerregistra Rooms pelo processo; numa instância de Room — a interface administrativa por instância: ajusta configuração ao vivo, expulsa, tranca, transmite, descarta
entry-validatoraceita ou rejeita pedidos de entrada com código e razão
room-visitornavega, entra com dados, reconecta dentro da janela de tolerância, sai
spectatorentra sem disputar; recebe transmissões e tráfego ao vivo
match-organizerreserva vagas que contam para a capacidade; uma reserva expira no prazo do template (90s em battle)

De relance

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Onde quer que tenha sido escrito, o template enviado é versionado e editável no painel, então o live-ops reajusta um tipo de Room sem novo deploy da engine. Hospedar Rooms construídas a partir dele é a mesma superfície alcançada por um papel diferente, e tanto um servidor dedicado Unreal quanto um master-client seguram tudo isso — registrar, hospedar vários por processo, ajustar configuração ao vivo, expulsar, publicar, descartar.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Cliente:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

O modelo

Uma Room é um Group com regras, e não é uma Entity. A associação vem de Groups; o estado de sistema dela é de nível de plataforma, enquanto o estado do jogo mora em Entities no escopo dela. E nenhum código de consumidor roda dentro de uma Room — em nenhum dos modos de autoridade.

O que um tipo de Room declara.

DeclaraO que é
capacityem vagas, e uma vaga é a unidade de capacidade separada da associação: ela pode ser reservada antes de uma entrada e mantida durante inatividade. Uma reserva é limitada no tempo, com prazo declarado após o qual a vaga é liberada sem entrada
visibilityenumerável · por nome ou código · oculta
creation modeum de três, e o modo on first join é obrigado a declarar um Hook de inicialização
two independent timeoutso timeout de inatividade — por quanto tempo um participante pode ficar em silêncio; e o TTL de Room vazia — por quanto tempo uma Room sem ninguém sobrevive. Duas perguntas diferentes, logo duas Declarations
rejoin windowdentro dela um retorno restaura a mesma associação e a mesma vaga, em vez de criar um participante novo
authority modeour simulation ou external authority, e não há padrão
trust in a reported outcomepara autoridade externa: aceitá-lo · conferi-lo com um Hook · não aceitá-lo. De novo sem padrão
behaviour when the host dropsesperar a janela de tolerância · fechar a Room · admitir um substituto
map instance and world strataopcionalmente, qual instância ela ocupa e quais estratos dentro dela
room-scoped entitiesquais Entities do estúdio têm o escopo da Room — exprimido por um predicado, não por um mecanismo novo

As duas máquinas.

DeEstados
uma Roomcreated → open → closed → torn down, onde torn down é terminal e closed quer dizer sem entradas novas, não sumida
uma associaçãoactive ⇄ inactive → departed, com departed terminal para aquela associação

O que vale para toda Room.

SempreO que é
losing a connection and leavingsão eventos diferentes, e o desfecho da janela é observável: "voltou" e "a janela expirou" são distinguíveis, então um cliente nunca fica adivinhando qual dos dois aconteceu
no replayuma reconexão retoma a partir do estado da sessão; o módulo não promete os Events do intervalo
a spectatornão é um participante degenerado: presente, ocupando vaga nenhuma, e fora do roster endereçado como "os jogadores" — do contrário toda operação sobre o roster carregaria uma condição
no in-room rolesum dono de Room é um Actor segurando um direito (Access & Roles), não uma patente na lista de membros
presencetem histórico, o roster não: quem entrou, caiu, voltou e saiu é guardado; as mudanças do roster não são um segundo histórico
the interfaceé de uma Room específica: você endereça esta Room, não apenas o tipo dela
three axes, not twoa API que abrange toda Room (navegar, registrar, listar); a API por Room que todo membro chama (entrar, sair); e uma interface administrativa por instância — expulsar, trancar, ajustar configuração, fechar, descartar esta Room —, aberta a quem segura o papel de admin ou de host daquela instância, e não à associação
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

Os dois modos de autoridade.

ModoQuem conduz o Tick
our simulationa nossa implementação de Room e os módulos dela
external authorityum processo rodando o nosso SDK, dentro da Room sob um papel autoritativo: o servidor de jogo do estúdio, ou o cliente de um jogador como master-client

O que o modo decide, e o que não decide.

O que é
the lineé traçada por papel, não por de quem é o processo. Um servidor dedicado é o mesmo cliente sem a renderização; o que o separa da máquina de um jogador é confiança, não construção — que é também por que peer-to-peer não precisa de um terceiro modo, sendo uma Room em modo de autoridade externa cuja autoridade é um host cliente
what is identicalregras de entrada, presença, reconexão e toda Declaration, nos três. O que difere é só qual processo segura a autoridade e quanto dela lhe é concedido
the room does not movenenhum código de consumidor roda dentro de uma Room em nenhum dos modos, e o estado de sessão e o persistente ficam conosco nos dois. Um master-client é um membro segurando um papel autoritativo: o Tick é computado ali, a Room não mora ali
trust in the outcomeé uma Declaration separada no tipo de Room — aceitá-lo · conferi-lo com um Hook · não aceitá-lo, e sem padrão — em vez de propriedade do modo

Hospedar uma Room.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
SempreO que é
the handleé o mesmo objeto que a aba de cliente usa: não há bootstrap de host nem handle exclusivo de servidor. Ele responde a estas chamadas porque o papel do Actor as inclui em runtime — um servidor dedicado ou um master-client rodando sob uma chave de host (Access & Roles)
registrationaceita uma chave de idempotência, porque um timeout nela seria irrecuperável de outro modo: repita a chamada com a mesma chave e você recebe a mesma Room de volta, não uma segunda cujo endereço ninguém conhece

Entrada, presença e reconexão.

O que é
entryé um pedido validado: quem entra fornece dados na entrada e o Hook de entrada aceita ou rejeita com código e razão. Esses dados são a partir de que o membro se inicializa — em battle, o loadout levado na entrada é com o que o Tank do membro nasce
seats and reservationsMatchmaking reserva uma vaga pelo prazo de reserva do template — 90 segundos em battle — e o cliente então entra diretamente. Reservas contam para a capacidade, e o vencimento libera a vaga com um Event em vez de em silêncio
a drop is not a leaveum membro desconectado mantém a vaga dele pela janela de tolerância (45s em battle) e reconecta na mesma associação; o vencimento a transforma numa saída, e o Event carrega qual das duas foi. A quem volta um segundo atrasado se diz que a Room está viva e a associação não — uma resposta diferente de "não existe tal Room", de propósito
late join is state, not a journalquem entra recebe o estado atual da Room e depois tráfego ao vivo. Events enviados enquanto estava fora não são reproduzidos, e os que um membro que retorna perdeu também não: tudo o que precisa sobreviver ao intervalo é estado — a mina que um jogador plantou é uma Entity no escopo da Room, não uma mensagem MinePlaced que alguém precisa pegar

Erros

  • Uma Room que não existe ou está escondida por um predicado, e uma Room descartada, respondem not found — uma recusa nunca revela uma Room que você não pode ver.
  • Capacidade esgotada é conflito, e vagas reservadas contam como ocupadas; vale repetir quando uma vaga abrir. Fechada a entradas é igualmente conflito, vale repetir se abrir.
  • Uma entrada rejeitada por uma regra é conflito; rejeitada por um Hook carrega o código e a razão do próprio Hook, para que "uma regra do jogo disse não" nunca chegue parecendo falha de transporte.
  • A janela de retorno expirou é conflito: entre como participante novo, com vaga nova.
  • Uma reserva vencida é conflito: pegue outra.
  • Uma instância de mapa indisponível ou inexistente é recusa de validação.
  • Uma transferência que a Room de destino rejeitou é conflito, e o que fazer depende da razão.
  • Criação acima do limite de Rooms responde como limite de taxa ou como conflito conforme qual limite tenha sido.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • A capacidade de uma Room — uma entrada é recusada como conflito, com vagas reservadas contadas como ocupadas.
  • Rooms por projeto — a criação é recusada como conflito.
  • Rooms por Actor — a criação é recusada, e Rooms já criadas nunca são descartadas para abrir espaço.
  • A taxa de criação de Rooms — limite de taxa com prazo.
  • O timeout de inatividade de um participante — saída forçada com um Event e razão declarada.
  • O TTL de Room vazia — descarte com Event; desativável no tipo de zona persistente.
  • O prazo de reserva de uma vaga — liberação com Event.
  • Rooms numa instância de mapa — criar numa instância ocupada é recusado a menos que o tipo declare ocupação compartilhada.
  • O tamanho do payload de um Event de Room — a publicação é recusada antes do envio, nunca truncada.

Fluxo do usuário

Uma partida num servidor dedicado, do sign-in do jogador ao HUD mostrando quem entrou.

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

Quem vê o quê, e qual máquina executa

Duas perguntas que soam como uma. Quem vê o quê é sobre um cliente: qual fatia do estado da Room chega a qual jogador. Qual máquina executa é sobre um host: qual processo é dono de uma Entity, e qual será o dono em seguida. A palavra que junta as duas é replication: num motor de jogo ela costuma nomear a primeira, e aqui nomeia a segunda.

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
Se você quer dizerLeia
qual cliente recebe qual estado, e quanto deleVisibility, com Data e Prediction
qual máquina é dona da Entity, e o que acontece quando ela morreWhat Survives Losing a Host

Elas são declaradas em dois lugares diferentes

Nenhuma das duas é configurada em tempo de execução, e elas não dividem uma Declaration.

Declarado emQue nomeia
quem vê o quêo aspect — Data, Visibilityo predicado de visibilidade, o teto de objetos e a ordem dele, quais áreas vizinhas são visíveis, e o modo de entrega
qual máquina executao tipo de Room — Roomso modo de autoridade, o quanto se confia numa autoridade externa sobre um desfecho, e o comportamento quando o host cai

As duas também diferem no que acontece se você não disser nada. Um aspect sem regra de visibilidade própria é entregue no pacote compartilhado, que é o padrão e é o certo para uma Room pequena. Um tipo de Room que não nomeia modo de autoridade é recusado: não há padrão, porque nada pode escolher por você entre a nossa simulação e uma externa.

The live game

Visibility

Com 40 jogadores um snapshot da Room inteira está de bom tamanho. Com 200, não. Uma zona de visibilidade decide quem recebe o quê, como predicado declarado em vez de um interruptor que você aciona por objeto. Broadcast e pacotes por Actor são dois modos de entrega declarados de um modelo só, então passar de um para o outro é configuração e não reescrita. É uma otimização de canal e não uma permissão — para isso, veja Access & Roles.

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

Um modelo declarado — o predicado, as camadas, os níveis de detalhe — é lido de dois jeitos. Passar de um para o outro é configuração, não reescrita, porque ambos são leituras da mesma Declaration.

BroadcastPacotes por Actor
Enviaa Room inteira, para todosa cada jogador apenas a fatia que as regras dele selecionam
Serve parauma Room pequena; este é o padrãouma multidão, onde o tamanho do pacote precisa ser previsível
Lê a Declarationuma vez, para a Roompor Actor

O que não pode vazar está ausente do pacote em vez de escondido no cliente — nunca enviado, o que faz disso propriedade de segurança e não de banda.

Quando usar

  • As suas Rooms cresceram além do broadcast da Room inteira — 200 jogadores precisam de fluxos de vizinhança por cliente, não de todo Delta.
  • Estado não pode vazar: névoa de guerra e campos só do dono nunca devem ser enviados, não escondidos no cliente.
  • Várias sessões compartilham um mapa e não podem se ver — uma camada é mais um predicado.
  • O tamanho do pacote precisa ser previsível numa multidão — limite o número de objetos e declare a ordem, para que "os N mais próximos" seja uma promessa e não um acaso da densidade.
  • Um jogador numa fronteira precisa enxergar além dela — declare quais áreas vizinhas são visíveis, porque o padrão é apenas a própria e uma fronteira de outro modo se lê como uma parede de vazio.
  • Pule quando a Room é pequena — o modo de entrega por pacote compartilhado já cobre isso.

Quem faz o quê

ActorNesta página
schema-authordeclara o predicado de visibilidade, o teto de objetos e a ordem dele, quais áreas vizinhas são visíveis, e o modo de entrega
anyassina e recebe o que a zona admite; pode baixar o teto de objetos para si dentro dos limites declarados

De relance

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

O modelo

O que uma regra de visibilidade declara.

DeclaraO que é
predicatea regra em si, na mesma linguagem de predicados dos predicados de acesso e das guardas de transição. Um raio, uma instância de mapa, um time e a propriedade são casos particulares de predicado, não mecanismos separados — existe exatamente uma dessas linguagens no contrato
object cap e a ordem deleuma regra pode limitar o número de objetos, e então a ordem de seleção é declarada em vez de inferida: "os N mais próximos" é um predicado mais ordenação por distância mais um teto. Um predicado sozinho não consegue exprimi-lo, porque um predicado responde "esta linha qualifica", não "qual das qualificadas está mais perto"
neighbouring areasse objetos de uma Room ou instância de mapa vizinha são visíveis, e quais exatamente. Nunca implícito: sem Declaration, a área é aquela em que o destinatário está
delivery modeshared packet — a mesma coisa para todos, barato de CPU; ou per-actor packet — cada um o seu conforme a zona dele, caro de CPU e necessário em populações grandes

O que vale para toda zona.

SempreO que é
visibilitynão é permissão: o que a zona esconde pode estar disponível por permissão, e o contrário. O primeiro é otimização de canal, o segundo é segurança — e confundi-los quer dizer que uma configuração de névoa de guerra amplia permissões em silêncio, ou que ACLs passam a ser usadas para economizar banda e direitos passam a depender de distância
the recipientpode baixar o teto: dentro do máximo declarado e nunca abaixo do mínimo declarado, porque um predicado é o mesmo para todos cujo contexto casou, enquanto o tamanho de pacote é problema do destinatário
truncationé observável: o destinatário fica sabendo que o pacote foi cortado e por qual ordem. Truncar em silêncio é proibido — é indistinguível de não haver mais objetos
degradationé declarada: quando o orçamento de montar pacotes por Actor acaba, a plataforma recai no pacote compartilhado conforme declarado, em vez de começar a perder destinatários arbitrariamente: pior, mas de um jeito conhecido, em vez de um vazamento indistinguível de um bug de jogo
packet shapeé uma promessa fraca: o tamanho e a composição de um pacote não deveriam permitir inferir a existência de objetos escondidos, e isso é deliberadamente mais fraco que um MUST — esconder por completo os metadados de fluxo em volumes reais é inatingível. Onde o vazamento de existência importa, use permissões, não a zona

Erros

  • Um target de assinatura não declarado é recusa de validação.
  • Sem permissão para assinar responde forbidden ou not found conforme a existência do target seja segredo ou não — a própria recusa não pode vazar aquilo que está recusando.
  • Uma posição de retomada que não faz parsing é requisição inválida, não um recomeço silencioso a partir de agora.
  • Uma assinatura que a plataforma fechou, e uma contagem de assinaturas esgotada, são ambas conflitos.
  • Ampliar uma vista é fn. Conceder uma vista de exceção e definir os níveis de detalhe são recusados forbidden a uma sessão de jogador, e a vista dela fica intacta: um cliente espectador não pode ampliar a própria concessão.
  • Uma instância que a vista de quem chama exclui responde not found, a mesma resposta de uma que não existe — um forbidden confirmaria que algo está atrás do muro.
  • Ler o custo de pacote por Actor é fn adm — uma função cloud ou o painel, nunca um cliente perguntando quanto custa ser observado.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • O custo de um pacote por Actor — ao esgotar, degradação declarada para o pacote compartilhado com aviso, nunca perda arbitrária de destinatários.
  • Objetos por regra — limitados com ordem declarada e sinalizador de truncamento observável.
  • Assinaturas por Actor — uma nova é recusada e as existentes continuam.
  • Tamanho do Delta — o Delta é dividido em vez de truncado, e a divisão é observável.
  • A taxa de envio — um limite superior, não uma garantia.

Fluxo do usuário

Uma regra de raio transforma uma Room de 200 jogadores em fluxos de vizinhança por cliente.

"Quem vê isto?" e "o que eles veem?" são ambas consultáveis, porque a sessão de depuração em que você não consegue respondê-las é a cara. O custo de pacote por Actor é uma leitura de primeira classe, em código e no painel.

The live game

O que sobrevive à perda de um host

Um host morre no meio da partida. A partida, não. Esta página é sobre o segundo sentido da palavra "replicação" — qual máquina é dona de uma Entity, e qual máquina será a dona seguinte. O primeiro sentido, qual cliente recebe qual estado, é Visibility com Data & Subscriptions e Prediction & Lag Comp. Quem vê o quê, e qual máquina executa é onde os dois são separados.

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

O estado da Room não é copiado entre hosts

Uma Entity tem exatamente um dono por vez, e nenhuma segunda máquina guarda uma cópia viva pronta para assumir.

Duas cópias aceitando o mesmo tiro teriam de concordar sobre a ordem em que os dois tiros caíram. Concordar sobre ordem trinta vezes por segundo, entre máquinas, é consenso — e consenso põe latência exatamente onde um jogo não a tolera. Um dono único não tem esse problema, e todo mecanismo abaixo existe para tornar um dono único sobrevivível, e não para contorná-lo.

O que é replicado é a presença: qual Actor está em qual nó. Esse é um fato pequeno e de mudança lenta, então o roteamento pode conhecê-lo em toda parte sem pagar por acordo sobre nada que se mova.

Estado declarado fica guardado fora do host

Estado declarado não é privado do processo que o segura. Ele é capturado num intervalo declarado, então uma substituta pode retomar a partir do último snapshot quando o host anterior para de responder, e o jogador entra de novo pela janela de tolerância comum de Rooms.

Três coisas decorrem disso, e são a forma honesta do arranjo:

  • A substituta tem o estado inteiro, mas como estava no snapshot. Completo, não atual. O que uma falha custa é o jogo entre o último snapshot e a perda, e é o intervalo que fixa esse pior caso.
  • A continuidade dos Ticks não atravessa uma troca de autoridade. Uma transferência que a plataforma realiza preserva o estado de Tick do participante; uma substituição da autoridade não o promete. Rooms é onde ambas são declaradas, junto com o que acontece quando a janela de tolerância passa.
  • Tudo o que você guardou apenas em atores da engine vai junto com o processo. Nunca foi declarado, então nada fora daquele host jamais o teve.

Um deploy é o mesmo caminho, sem a perda

Drenar um host — parar de colocar Rooms novas ali, deixar as sessões em voo terminarem ou serem transferidas, e então soltá-lo — é o caminho de tolerância a falhas acionado de propósito e com aviso. É por isso que implantar sem matar sessões vivas não é um segundo mecanismo a construir e em que confiar: é este, iniciado deliberadamente em vez de por uma queda.

O host da Room fica sabendo do mesmo jeito que fica sabendo de qualquer coisa: a plataforma avisa com antecedência que uma Room será fechada ou transferida por uma razão do lado dela.

O que acontece quando a janela passa é declarado, e não há padrão. Um tipo de Room cuja autoridade mora fora da plataforma nomeia um de três desfechos para perdê-la — esperar uma janela declarada, fechar a Room, ou admitir uma autoridade substituta. Deixar isso por dizer não é uma opção que a Declaration ofereça, porque a alternativa é justamente a falha que ela existe para evitar: uma Room com autoridade morta que ainda aceita entradas e mantém vagas, mostrando a todo participante uma sessão viva na qual nada acontece.

Qual máquina não faz parte da sua superfície

Você nunca nomeia um nó. Quem cria uma Room não escolhe onde ela roda, e operação alguma recebe um host como argumento — a colocação é da plataforma, e continua sendo, para que ela possa mover uma Room sem que o seu código tenha sido escrito contra onde ela costumava estar.

Se você mesmo hospeda Rooms — um servidor dedicado ou um master-client — o mesmo vale com um acréscimo: você é avisado para encerrar, e terminar ou transferir as suas sessões dentro da janela de tolerância é com você. Rooms é onde um host se registra para esse binding, e Autoridade é por que o host segura apenas os direitos que lhe foram concedidos.

The live game

Matchmaking

Levar um jogador até a Room certa. Tickets descrevem o jogador e filtram os outros. O matchmaker resolve uma colocação, reserva uma vaga, e a partir daí o tráfego de jogo flui direto para a Room.

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

O matchmaker está no caminho uma vez, para decidir a que lugar você pertence. Ele não está no caminho da partida: o desfecho dele é uma colocação e uma reserva de vaga limitada no tempo, e a partir da entrada o tráfego de jogo vai direto para a Room. Uma fila ocupada, portanto, nunca vira um jogo ocupado.

Quando usar

  • Você precisa rotear jogadores para Rooms por critérios declarados — modo, região, rank — e não por uma lista de lobby feita à mão.
  • Os critérios de partida precisam vir de dados da plataforma, não da alegação do cliente: carimbe o rank no Hook de pré-enfileiramento.
  • As filas devem alargar com o tempo no servidor enquanto o cliente segura um ticket e nunca consulta.
  • As parties precisam cair na mesma partida juntas — um Group entra inteiro ou não entra.
  • Você opera um matchmaker externo e só precisa que a decisão dele termine em colocação + reserva de vaga.
  • Pule quando os jogadores escolhem a sessão eles mesmos — o navegador de Rooms e o Join já cobrem isso.

Quem faz o quê

ActorNesta página
playercria e cancela o próprio ticket, e entra como parte de uma party
match-organizerdeclara filas de matchmaker e o relaxamento delas; lê resultados de colocação
backend-servicecarimba critérios confiáveis antes do enfileiramento; executa decisões de matchmaker externo

De relance

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

Critérios em que não se pode confiar no cliente são carimbados no Hook de pré-enfileiramento:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

A leitura é a leitura por lista de donos de Leaderboards — a mesma que uma turma de amigos usa — e toda linha devolvida carrega o rank daquele dono na tabela completa. O Hook pode pedir a linha de outra pessoa porque o predicado do Leaderboard permite isso a uma função de nuvem; uma sessão de jogador fazendo a mesma pergunta recebe só a própria.

O modelo

O que um ticket carrega, e as duas partes não se reduzem uma à outra.

ParteO que éQuem acredita nela
self-descriptionas propriedades declaradas do participante — rating, modo, idioma, mapa escolhidoninguém sem verificação: é a alegação de quem chama
requirementum predicado que os demais precisam satisfazera plataforma, porque é ela que o aplica

O participante de um ticket é um Actor ou um Group — um Group entra inteiro, e é isso que uma party é. O ticket dele é indivisível: o Group entra num roster inteiro ou não entra, porque dividir um Group seria outra promessa e não existe nenhuma.

O que um tipo de fila declara.

DeclaraO que é
propertiespor nome e tipo. Uma propriedade não declarada aqui é recusada num ticket como falha de validação em vez de ignorada
roster sizeum mínimo, um máximo e um passo de compatibilidade — o múltiplo em que um roster é aceitável, para que times "de cinco" queiram dizer cinco e não qualquer número entre dois e dez
requirement ladderum conjunto ordenado de predicados com janelas: cada degrau é um requisito mais largo e um tempo depois do qual o matchmaking segue adiante. O relaxamento é uma Declaration, nunca lógica arbitrária num handler
the predicate languagea mesma que todo o resto usa, e o vocabulário dela inclui as propriedades do próprio ticket — "um rating dentro de ±100 do meu" é exprimível. Sem isso o modelo de dois lados não funciona de forma alguma, porque condições relativas são todo o ponto dele
mutualityse um roster é aceitável quando A aceita B enquanto B não aceita A. Não há padrão
ticket lifetimedepois do qual o ticket transiciona para expired com um Event
outcomeRoomPlacement — uma referência de Room mais reservas nela, para um jogo simultâneo; ou RosterSet — só o roster, sem Room e sem reservas, para um assíncrono em que o adversário está offline

Os estados de um ticket. created → queued → matched · cancelled · expired, os três últimos terminais.

SempreO que é
one live ticket per participant per queueum segundo é conflito, não uma segunda inscrição — leia o existente
the reason for a pairingé observável: chega ao Event de matchmaking e ao histórico. Para os algoritmos entregues isso é o degrau da escada; uma implementação que sobrescreva pode não ter degraus, e então a razão é um valor opaco que ela declara — mas sempre há uma
expiryé um desfecho, não um erro: "um roster não se juntou no tempo declarado" é uma conclusão normal entregue como o desfecho do ticket
the outcomechega por assinatura: não por consulta repetida. O matchmaking leva segundos e dezenas de segundos, então consultar transformaria a espera em carga que cresce com o tamanho da fila — o cliente segura um ticket e nunca pergunta de novo
losing the connection cancels the ticketdeclarado em vez de inferido: um ticket é uma inscrição para jogar agora, e casar um jogador ausente piora o roster para todos os outros
matchedé atômico: para RoomPlacement, ou o roster está casado e todo participante segura uma reserva, ou os tickets ficam na fila. Para RosterSet o resultado atômico é só o roster

Erros

  • Um segundo ticket na mesma fila é conflito; não o repita, leia o ticket existente.
  • Uma propriedade não declarada, ou um requisito nomeando uma, é falha de validação — não um ignorar silencioso que apareceria depois como "nenhum adversário foi encontrado".
  • A fila está suspensa responde unavailable, não forbidden: os direitos de quem chama estão intactos e a situação é temporária, então repetir com backoff é o certo.
  • A Room do resultado não pode ser criada é igualmente unavailable, com backoff.
  • A reserva falhou é um conflito que vale repetir — o ticket fica na fila.
  • Um ticket não encontrado ou de outra pessoa, e um Actor que um predicado não admite na fila, respondem ambos not found, então uma recusa não revela nem o ticket nem a fila.
  • "Um roster não se juntou" nunca é erro — veja o vencimento acima.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Tickets numa fila — a criação é recusada como conflito, e tickets existentes não são descartados para abrir espaço.
  • O tempo de vida de um ticket — transição para expired com um Event.
  • Degraus da escada — uma Declaration com degraus demais é recusada no momento da declaração.
  • Tamanho de Group num ticket — o ticket é recusado como falha de validação.
  • Propriedades declaradas por tipo de fila — recusadas no momento da declaração.
  • Taxa de criação de tickets — recusa por limite de taxa com prazo.
  • Retenção do histórico de matchmaking — passado o período, uma entrada fica ilegível pelo período declarado.

Fluxo do usuário

Do sign-in até estar de pé na Room da partida, com o rank carimbado no servidor. A jornada começa em Auth & Players porque um ticket tem dono: sem sessão não há quem enfileirar.

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Map

O mundo estático: limites, terreno, obstáculos e "para onde as coisas podem ir?". O modelo físico é deliberadamente muito mais simples que o visual: primitivos com uma pegada e uma altura, camadas com regras, e uma consulta de posição válida que todo outro módulo reaproveita.

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Você precisa de um mundo estático — limites, terreno, obstáculos — que o servidor possa consultar, e não apenas desenhar.
  • Spawns, drops e decorações precisam cair em pontos legais: uma consulta RandomPosition baseada em regras, sem atalho.
  • Arenas devem ser regeradas por partida — um Seed declarado reproduz o mesmo mapa num relatório de bug.
  • Caixas e paredes quebram e voltam — destrutíveis com HP e temporizadores de respawn.
  • Bots e Projectiles precisam de respostas de raycast e de linha de visão contra o conjunto de obstáculos.
  • Pule quando o mundo é puramente visual e código de servidor algum pergunta para onde as coisas podem ir.

Quem faz o quê

ActorNesta página
schema-authordeclara mapas, primitivos de obstáculo, destrutíveis, camadas e as regras delas
room-ownerliga um mapa a uma Room; pede posições de spawn; faz raycasts
operatorcoloca ou remove obstáculos e camadas pelo painel

De relance

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter e Destructible são geradores de colocação, não sorteios em runtime. Um gerador resolve quando a versão do mapa é publicada: as quarenta rochas viram quarenta primitivos declarados, e a versão publicada carrega os primitivos, não a regra. O mesmo Seed, portanto, dá as mesmas quarenta rochas na partida, no replay e no relatório de bug — e os limites de geometria são verificados uma vez, sobre esse conjunto resolvido, antes que a versão alcance um ambiente.

A consulta que todo o resto faz:

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

O modelo

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

Duas camadas, declaradas por pessoas diferentes.

CamadaO que guarda, e quem a declara
staticterreno com altura, primitivos de obstáculo, limites e locais — conteúdo autoral
dynamicobstáculos trazidos por Entities em runtime: portas, destrutíveis, plataformas. Um destrutível é, portanto, uma Entity com ciclo de vida, estados e dono, e vira mapa apenas na parte em que traz um obstáculo — uma rocha estática é declarada no mapa, uma porta é uma Entity que traz uma. Estas não têm ciclo de vida próprio aqui: ele pertence à Entity

O que um mapa declara.

DeclaraO que é
key e versionum mapa é conteúdo autoral: declarado em código, endereçado por key e versionado — a versão é parte do que uma Room referencia. Mudar a geometria de uma versão liberada é proibido; uma edição é uma versão nova
terrainum campo de alturas — uma grade regular com passo declarado, e o passo é um limite declarado de precisão, então uma consulta de altura responde por ele em vez de exatamente. O terreno pode estar ausente: uma arena no vazio é legítima
obstaclesum conjunto fechado de primitivos — caixa, esfera, cápsula, casco convexo com limite declarado de vértices. Uma malha triangular arbitrária não é aceita, que é a condição de uma verificação no servidor ser possível de todo
passability kind por obstáculointransponível · transponível por uma classe declarada · bloqueia apenas a linha de visão. Um primitivo serve como parede e como arbusto, e a diferença é declarada em vez de modelada duas vezes
boundso volume fora do qual uma posição é inadmissível
world strataestratos espaciais declarados dentro de um mapa — solo, subsolo, ar. São Declarations de geometria e de endereçamento
locationslugares ou áreas nomeadas — um ponto de spawn, uma zona de captura, um corredor. Um local responde onde, nunca o que acontece: ele não carrega lógica de jogo
placement generatoropcionalmente, uma regra que produz primitivos — uma contagem, um espaçamento mínimo, uma área, uma semente. Ele é resolvido quando uma versão é publicada, deterministicamente pela semente, e a partir daí o mapa guarda primitivos em vez de uma regra

Um estrato de mundo e uma instância de mapa nunca são sinônimos.

O que é
world stratumuma Declaration dentro do mapa — solo, subsolo, ar
map instanceuma cópia em runtime independente do mapa publicado. Instâncias compartilham a geometria publicada imutável e têm obstáculos dinâmicos independentes e listas de Entities independentes. Uma Room ocupa uma instância e pode selecionar estratos dentro dela

O que vale para toda consulta.

SempreO que é
one geometric canontoda a geometria está no cânone de coordenadas declarado da plataforma, e a precisão de todo campo geométrico é declarada no campo
the world modelé uma simplificação: a geometria do servidor não é o modelo de arte, e não é obrigada a ser
an answer names its instance and its momentuma consulta é respondida a partir da camada estática do mapa mais os obstáculos dinâmicos da instância sobre a qual perguntou, e ela declara o momento para o qual é verdadeira — obstáculos dinâmicos mudam, então a resposta é um snapshot
the valuessão gerenciados, não semeados: geometria não é o ajuste diário de um designer — uma edição do console administrativo é recusada em vez de mantida em silêncio
movement and contactnão são resolvidos aqui: ele responde o que o espaço é; se uma posição é admissível e qual a resposta pertence a Collision, e aplicá-la a Locomotion

Erros

  • Um mapa, uma versão ou uma instância não encontrada responde not found, e uma versão retirada também — repetir é inútil.
  • Publicar geometria alterada sob uma versão existente é conflito: faça uma versão nova.
  • Falhas de publicação caem na declaração, no deploy, nunca em runtime — um mapa além do limite de primitivos, um casco convexo além do limite de vértices e uma malha arbitrária como obstáculo são todas recusas de validação antes que algo seja publicado.
  • Uma consulta de altura fora dos limites não é erro — é a resposta declarada "fora dos limites", e ela é distinguível de "dentro de um obstáculo", porque num caso um cliente se vira e no outro contorna.
  • Taxa de consultas excedida responde na categoria de limite de taxa, com prazo.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Primitivos de obstáculo por mapa, vértices de casco convexo, resolução do campo de alturas, tamanho dos limites, locais por mapa — cada um deles é recusado na publicação, não no momento da consulta: um mapa que é publicado é um mapa que já cabe.
  • Instâncias de mapa por mapa — criar mais uma é recusado como conflito; instâncias existentes nunca são liberadas para abrir espaço.
  • Versões mantidas — a versão em depreciação mais antiga é retirada, e uma versão sob uma Room viva nunca é.
  • A taxa de consultas ao espaço — limite de taxa com prazo.

Fluxo do usuário

Um airdrop agendado pede ao mapa um ponto legal, e um jogador dirige até lá para pegá-lo. A DropTable é um Entity Preset — uma Declaration numa Entity, não um módulo que você monta.

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Collision

Ligue um transform ao mapa de obstáculos; declare o que o contato faz. Collision roda dentro da simulação da plataforma. Você declara corpos, camadas e respostas, e assina os contatos.

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

Quando usar

  • Entities em movimento precisam resolver contatos no servidor — deslizar, parar, quicar — sem uma rotina de deflexão escrita à mão.
  • A jogabilidade reage ao toque: itens são apanhados por sobreposição, volumes de gatilho disparam uma máquina de estados de Entity.
  • Locomotion e Projectiles precisam de resolução varrida contra o conjunto de obstáculos do Map.
  • Prévias de colocação ou mira precisam de "isto caberia aqui?" e de consultas de sobreposição de volume.
  • Pule quando nada se encontra fisicamente — jogabilidade de requisição/resposta sobre registros é Data & Subscriptions puro.

Quem faz o quê

ActorNesta página
room-ownerdeclara corpos, camadas e respostas; consulta sobreposições e contatos

A quais Rooms isto se aplica. Este módulo roda onde a plataforma avança a simulação — Rooms declaradas com Host = "Backend". Se o seu próprio game server é dono da simulação (PlayServ como metasservidor), movimento, colisão e predição ficam do lado do motor, e esta página descreve a alternativa hospedada na plataforma, não uma exigência.

De relance

Forma, camada e o que o contato faz ficam todos no próprio corpo — ninguém declara pares de camadas à distância:

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

Um contato é um Event, e os módulos o assinam. Não existe Hook num contato: quando um existe, o passo já o resolveu, então não sobrou nada a rejeitar. Onde um estúdio precisa de regras diferentes, ele sobrescreve as verificações de admissibilidade e de trajeto como implementação — veja Extensibility — e a resposta continua declarada.

O modelo

O que um corpo declara.

DeclaraO que é
shapeum primitivo de um conjunto fechado — esfera, cápsula, caixa — com dimensões declaradas. Uma malha arbitrária não está em oferta, a mesma restrição e a mesma razão do modelo de mundo do servidor em Map
where it livesnum aspecto, junto com o transform: essa é a unidade de política, e um corpo compartilha um destino com o transform dele
how its path is checkedstepwise — a posição final do passo é verificada, rápido, e um corpo rápido atravessa um obstáculo fino; ou swept — o segmento entre posições é verificado, mais caro, e o tunelamento fica excluído dentro de um passo. Declarado, nunca escolhido por uma implementação a partir da velocidade: se um projétil pode voar através de uma parede é propriedade do jogo, não otimização
areas it participates indentro de quais volumes ele é contado
its relation to the art modelnenhuma é exigida: um corpo é uma simplificação, e divergir do modelo de arte é admissível dentro de limites declarados

A resposta é declarada num par — o tipo de transponibilidade de um obstáculo × um tipo de corpo — e vem de um conjunto fechado:

RespostaO que significa
stopo movimento cessa na última posição admissível
slideo movimento continua ao longo do obstáculo pela componente que for admissível
bouncea direção é refletida e a velocidade multiplicada por um coeficiente declarado
dampo movimento continua com a velocidade multiplicada por uma fração declarada
passo obstáculo não afeta o movimento, mas o contato continua observável
cease to exista Entity termina — um projétil contra uma parede

Os coeficientes são valores declarados em vez de calculados a partir de massas e materiais — não há nem uma coisa nem outra neste contrato.

O que vale para toda verificação.

SempreO que é
every pairtem uma resposta: um par ausente é defeito de Declaration, recusado no deploy em vez de encontrado em combate
the response tableé legível pelo cliente: a mesma tabela pela qual a autoridade calcula, então um cliente e um servidor com uma Declaration respondem a um contato do mesmo jeito
reproducible within one authority, not across platformsa mesma entrada na mesma ordem dá o mesmo resultado dentro de um processo e um build. Resultados idênticos bit a bit em plataformas e builds diferentes não são prometidos, e um modelo de rede construído sobre a suposição de que colisões calculam igual em toda parte está construído sobre areia
simultaneityé declarada: quando dois corpos em movimento colidem dentro de um passo, a ordem de resolução é declarada e determinística. A ordem de percurso do armazenamento, a ordem de chegada da entrada e aleatoriedade não podem ser a base dela
one contact, one factum contato entre dois corpos é observável por ambos os lados como um fato único com um identificador único, não como dois Events independentes
extension points sit on the step, not on a contactantes do passo o transform pode ser mudado, depois dele há observação. Um contato já aconteceu, então não há nada a rejeitar; regras diferentes são uma sobrescrita declarada das verificações de admissibilidade e trajeto, e tal sobrescrita é obrigada a estar disponível também ao cliente
the modulenão move nada por si: ele responde se uma posição é admissível e qual a resposta; aplicar isso é de Locomotion
a contact is an eventque é por que os módulos assinam em vez de se acoplar: a máquina de estados de uma armadilha liga uma transição à entrada num volume de gatilho, Drops recolhe por sobreposição, e Projectiles resolvem acertos pela varredura deste módulo

Erros

  • "Inadmissível" é uma resposta, não um erro, e ela nomeia qual das três razões: fora dos limites, ocupado por um obstáculo estático, ou ocupado pelo corpo de outra Entity. Um cliente reage às três de modos diferentes — virar-se, contornar ou esperar — então colapsá-las em "não" custaria comportamento.
  • Falhas de Declaration caem no deploy, nunca no primeiro contato: um corpo com forma fora do conjunto fechado, um corpo num aspecto sem transform e um par sem resposta declarada são todos recusados no deploy. Uma colisão acontece em combate, e uma falha de runtime ali é observada como uma parede que sumiu.
  • A Entity ou o local não encontrado responde not found, e repetir é inútil.
  • Taxa de verificação excedida responde na categoria de limite de taxa, com o prazo antes do qual repetir é inútil.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Corpos numa Room — declarar mais um é recusado como conflito; corpos existentes nunca são removidos para abrir espaço.
  • Contatos por passo — o excedente nunca é descartado em silêncio: ou o passo é recusado, ou a ordem de corte é declarada.
  • O tamanho de um corpo em relação ao passo da grade do mapa — recusado no deploy, porque um corpo menor que o passo do campo de alturas atravessa o terreno, e isso não pode ser surpresa de runtime.
  • Áreas dentro das quais um corpo pode estar — o excedente é recusado no deploy.
  • A taxa de verificação por Actor — limite de taxa com prazo.
  • Corpos numa resposta de "quem está nesta área" — truncados por uma ordem declarada, e o sinalizador de truncamento é obrigatório.

Fluxo do usuário

Um volume de gatilho, uma máquina de estados e uma porta: os Events de contato fazem toda a fiação. A placa e a porta são World Objects — Entity Presets, não módulos que você monta.

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Locomotion

Você declara como uma coisa se move; ninguém escreve um integrador. Um modelo de movimento converte entrada numerada em movimento autoritativo, integrado com Collision, registrado para Prediction & Lag Comp e modificado por buffs, debuffs e terreno.

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Entities se movem por entrada de jogador — tanques, personagens, veículos — e o movimento precisa ser autoritativo no servidor.
  • Você prefere declarar velocidade, aceleração e limites de giro a escrever um integrador.
  • A jogabilidade empurra corpos: knockbacks com Impulse, Teleport e modificadores estilo lama com durações.
  • O movimento precisa parecer instantâneo: o mesmo modelo declarado avança no servidor e no laço de Prediction & Lag Comp.
  • Pule quando as posições mudam apenas em passos discretos — um campo sincronizado na Entity já cobre isso.

Quem faz o quê

ActorNesta página
schema-authordeclara modelos de movimento, restrições e ligações
room-owneraplica impulso, teleporte e modificadores a partir do host
playerenvia entrada numerada; lê o estado de movimento

A quais Rooms isto se aplica. Este módulo roda onde a plataforma avança a simulação — Rooms declaradas com Host = "Backend". Se o seu próprio game server é dono da simulação (PlayServ como metasservidor), movimento, colisão e predição ficam do lado do motor, e esta página descreve a alternativa hospedada na plataforma, não uma exigência.

De relance

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

A entrada do cliente é uma intenção numerada. A plataforma avança o movimento:

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

Verbos do servidor:

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

O modelo

O que uma Entity declara para se mover.

DeclaraO que é
movement modelum do conjunto entregue — steering, tank, character, vehicle, flying — como várias implementações de um passo, com condições de escolha declaradas e um padrão. O passo é uma função pura (pose, input, dt) → pose
parametersvalores declarados que o cliente pode ler; sem eles a Prediction diverge sistematicamente. Eles são seed — um designer os ajusta e um deploy não pode perder as edições em silêncio — enquanto os limites em que o anticheat se apoia podem ser managed, e então uma edição do console administrativo é recusada
limitsvelocidade máxima, aceleração e frenagem, giro máximo por entrada, o multiplicador de ré e uma velocidade de rotação separada para partes. O giro por entrada é declarado à parte da velocidade de rotação de propósito: um limita um salto instantâneo, o outro uma taxa contínua, e são defesas diferentes
behaviour on stale inputstop, continue until a declared deadline ou continue indefinitely. Não existe padrão "siga como antes" — um jogador cuja rede caiu continuaria dirigindo
pose tolerancequão longe uma pose alegada pode estar da do servidor, e ela pode variar por estado: parado, em movimento e logo depois de um respawn são três tolerâncias diferentes
step rate and catch-up capcom que frequência o passo roda, e quantos passos podem ser dados de uma vez quando o servidor está atrasado
impulse kindscada um com a magnitude dele e o modo de decaimento dele

O que vale para todo passo.

SempreO que é
the module ownsa posição e a orientação de uma Entity ao longo do tempo, e mais nada. O histórico dessas posições é mantido por Entity em vez de aqui, então "onde o jogador estava 300 ms atrás" tem exatamente uma resposta em vez de dois buffers com períodos diferentes
authorityé do servidor: sob o modo our simulation um cliente envia uma intenção, nunca um resultado
collisionsnão são resolvidas aqui: ele pergunta a Collision se uma posição é admissível e qual a resposta, e não mantém tabela de respostas própria
inputé uma intenção: "frente", "direita", "gire a torre para lá" — aceita como vem, porque não afirma nada sobre o mundo
a claimed poseé uma alegação: nunca um fato. Fora da tolerância declarada ela é limitada à pose admissível mais próxima, e isso produz um pose_clamped observável
input sequencingé exigida: o mesmo número de sequência nunca é aplicado duas vezes, e um menor é descartado
a limitfaz clamp, não recusa: "dez metros à frente neste Tick" vira o que for admissível em vez de um erro. É isso que faz de um limite um anticheat por construção — o servidor fisicamente não consegue produzir a pose ilegal — e é por isso que o cliente não é inundado de recusas a cada quadro
an impulse obeys the same constraintsrecuo, um empurrão, uma explosão e um knockback chegam de fora da entrada, e nenhum deles contorna Collision: o recuo não enfia um tanque numa rocha
identical rules, not identical bitsresultado idêntico bit a bit entre plataformas não é prometido. O que é prometido são as mesmas regras, e reprodutibilidade dentro de uma autoridade
the stepé puro e dirigido por Tick: o mesmo código avança o movimento no servidor e dentro do laço de predição do cliente, que é o que torna a reconciliação exata

Erros

  • Um modelo de movimento ausente na Entity, um preset preenchido pela metade e um impulso sem decaimento declarado são todos falhas de validação no deploy, não em runtime — um impulso sem fim é defeito de Declaration, então ele nunca alcança um jogador.
  • A geração esperada não casou é falha de pré-condição, que vale repetir depois de reler: entrada enviada antes de um respawn não pode ser aplicada depois dele.
  • Taxa de entrada excedida responde na categoria de limite de taxa, com prazo.
  • A Entity não é controlável é conflito, e repetir só faz sentido depois que o estado mudar.
  • Três coisas não são recusa em direção alguma, e as três são observáveis. Entrada obsoleta é descartada, uma intenção além de um limite é limitada, e uma pose além da tolerância é limitada como pose_clamped. Fazer qualquer uma delas em silêncio deixaria o cliente acreditando que aplicou e divergindo do servidor de vez.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Velocidade e aceleração máximas — limitadas, nunca recusadas.
  • Giro máximo por entrada — limitado.
  • Taxa de entrada por Actor — limite de taxa com prazo.
  • Passos de recuperação — acima do teto os passos são descartados com uma consequência declarada: o tempo de simulação fica para trás e isso é observável, em vez de recuperado num salto que se lê como todo mundo teleportando ao mesmo tempo.
  • Magnitude de impulso — limitada ao máximo declarado.
  • Impulsos simultâneos por Entity — um novo descarta o mais antigo, e o descarte é observável; não há soma ilimitada silenciosa.
  • O tempo de vida de uma pose alegada — uma mais antiga que o período declarado não é considerada.

Fluxo do usuário

A jornada de um knockback: o player dirige, o attacker no outro tanque atira, e o impulso cai como uma pose reconciliada na tela da vítima. A ability e o projétil são Entity Presets — Declarations em Entities, não módulos que você monta.

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Prediction & Lag Comp

O jogador apertou pular 50 ms atrás. O pacote só chegou agora. Ele não caiu. Predição para a frente e compensação para trás sobre dados que carregam o tempo real do evento: o cliente sente instantâneo, o servidor continua certo, e os acertos são julgados na linha do tempo do atirador.

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • A entrada precisa parecer instantânea sob latência enquanto o servidor continua autoritativo — prediga para a frente, reconcilie na divergência.
  • Acertos precisam ser julgados na linha do tempo do atirador: ResolveAt rebobina hitboxes até o Tick de visão reportado.
  • Arcos de mira e marcadores de queda precisam bater com os desfechos — cliente e servidor projetam a mesma Trajectory.
  • Campos críticos para o jogo nunca podem sofrer rollback — declare o que prediz e o que espera pelo servidor.
  • O efeito elástico precisa de ajuste: janelas por Entity, tolerâncias e telemetria de predição errada.
  • Pule quando a latência não incomoda — jogos por turnos ou lentos rodam bem sobre Deltas comuns de Data & Subscriptions.

Quem faz o quê

ActorNesta página
schema-authordeclara campos preditos versus só autoritativos; define a janela de predição
room-ownerresolve acertos num estado histórico; rebobina o mundo
playerprediz e reconcilia o movimento; assina as correções

A quais Rooms isto se aplica. Este módulo roda onde a plataforma avança a simulação — Rooms declaradas com Host = "Backend". Se o seu próprio game server é dono da simulação (PlayServ como metasservidor), movimento, colisão e predição ficam do lado do motor, e esta página descreve a alternativa hospedada na plataforma, não uma exigência.

De relance

A palavra cobre três coisas diferentes, elas não podem ser fundidas, e cada uma tem o artigo dela. Elas têm autoridades diferentes e modos de falhar diferentes — uma palavra para as três quer dizer que ajustar uma muda as outras duas em silêncio.

MecanismoO que fazRoda emQuando erra
Predizer o próprio movimentoaplica o modelo declarado à sua própria entrada sem esperar pelo servidoro clienteuma correção, reproduzida e suavizada
Mostrar outros jogadoresdesenha outras Entities entre os estados que chegamo clienteum solavanco visível
Compensação de lagrebobina os alvos ao momento que o atirador viuo servidoralguém morre injustamente

Esta página é o eixo: o modelo comum, as Declarations comuns e os presets que escolhem uma combinação por você. Os três artigos são onde cada mecanismo é de fato explicado.

Quatro presets, e "sem predição" é um deles.

PresetPrediz o seuCompensaSuaviza os outros
shootersimnuma janela de cerca de um segundo e meiosim
arcadesimnãosim
observernãonãosim
sem prediçãonãonãonão — o estado chega da autoridade com uma janela de interpolação declarada

O último não é um esboço. Jogos por turnos, estratégias e a maioria dos títulos mobile não querem predição alguma, e um "não predizemos" declarado diz ao cliente para mostrar o estado como ele é em vez de adivinhar.

Nada disso se aplica sob autoridade externa. Os três mecanismos existem para Rooms que a nossa simulação conduz. Quando o servidor de jogo de um estúdio ou um master-client é dono do Tick, a predição é assunto de quem o conduz — veja Quem conduz o Tick.

O módulo não é dono de modelo de movimento algum, nem de tabela de reação, nem de geometria, nem de janela de histórico própria. Esses pertencem a Locomotion, Collision, Map e Entity, respectivamente. Prediction os aplica mais cedo ou os lê para trás; nunca declara uma segunda cópia.

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

A resolução com compensação de lag responde "onde estava todo mundo quando este tiro foi disparado":

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Projeção de trajetória, compartilhada por servidor e cliente (arcos de mira, marcadores de queda). A projeção é uma operação deste módulo, extrapolada contra o conjunto de obstáculos do Map, então os dois lados desenham o mesmo arco a partir das mesmas entradas:

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

O modelo

A previsibilidade é declarada num aspecto — e um aspecto mudado apenas pela autoridade, por regras que o cliente não tem, não pode ser marcado como previsível: isso é falha de validação no deploy, não surpresa em runtime.

O que é declarado.

DeclaraO que é
predictable aspectsquais deles o cliente pode avançar à frente da autoridade
divergence thresholdabaixo dele uma correção é suavizada; acima dele o estado da autoridade é aceito como vem. Declarado, e managed — não um botão diário de designer
display mode for remote entitiesinterpolação entre estados chegados, ou extrapolação
interpolation delayquão atrás o desenho dos outros fica, declarado em vez de ajustado no feeling
extrapolation windowalém dela uma Entity é marcada como obsoleta e a extrapolação cessa
compensation windowquão longe uma rebobinagem pode alcançar, e ela é managed
what is rewoundposições e orientações dos alvos, e a geometria de obstáculos dinâmicos se declarada histórica

O que é rebobinado, e o que deliberadamente não é.

O que é
rewoundas posições e orientações dos alvos, e a geometria de obstáculos dinâmicos onde o tipo os declara históricos
not rewoundestado de vida — os mortos não ressuscitam para levar tiro — e propriedade, pontuação e Inventory
the rule behind the splita decisão é tomada no passado; o efeito é aplicado no presente
SempreO que é
authoritative state names the input it sawele carrega o número da última entrada aplicada, que é o que torna a reconciliação exata em vez de aproximada
divergenceé observável: o cliente sabe que a predição dele foi corrigida, em vez de derivar em silêncio
a view timeé uma alegação, não um fato: o momento em que o Actor diz ter visto. Além da janela a plataforma recusa em vez de extrapolar: extrapolar em silêncio é um presente a um cheater, que só precisa enviar um tempo mais antigo
a rewind promises no reproducibility over floating pointa mesma restrição de todo o resto do contrato
one history ring, two consumersa reconciliação e as consultas com compensação de lag leem ambas a trilha de histórico instantâneo da Entity. Rebobinar pertence aqui em vez de aos módulos rebobinados: o anel restaura as poses do Tick em disputa, e então se faz a Collision a pergunta comum de sobreposição sobre essas poses — ele não mantém histórico próprio e nada sabe de um "Tick de visão"
tick do cliente:  aplicar entrada localmente (predizer) → armazenar → enviar, carimbado por tick
tick do servidor: avançar o mesmo modelo de movimento → estado autoritativo → Delta para fora
receber cliente:  estado autoritativo para o tick T → se divergência além da tolerância:
                  rebobinar até T → reproduzir as entradas armazenadas T+1..agora → suavizar

Apenas campos declarados como preditos sofrem rollback; deriva mínima é suavizada, divergência real rebobina e reproduz.

Erros

  • Um tempo de visão além da janela é conflito: envie um atual. Extrapolar em vez disso entregaria o mecanismo inteiro a um cheater.
  • Um pedido de histórico além da janela é igualmente conflito.
  • Dois defeitos de Declaration são pegos no deploy: uma janela de compensação maior que o buffer de histórico, e um aspecto marcado como previsível quando o cliente não tem regras para predizê-lo. Nenhum dos dois pode alcançar uma partida ao vivo.
  • Duas coisas não são erros, e ambas são observáveis. Um buffer de entrada cheio suspende a predição até a confirmação em vez de descartar entradas em silêncio; e divergência além do limiar quer dizer que o estado da autoridade é aceito como vem, que é a correção declarada e não uma falha.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • A janela de compensação — além dela uma recusa, nunca extrapolação.
  • A janela de extrapolação para os outros — a Entity é marcada como obsoleta e a extrapolação para.
  • O buffer de entrada não confirmada — a predição é suspensa até a confirmação; entradas nunca são descartadas em silêncio.
  • A profundidade de histórico para uma rebobinagem — não menor que a janela de compensação, e isso é conferido no deploy.
  • A taxa de ações que carregam um tempo de visão — limite de taxa com prazo.
  • Entities preditas simultaneamente por cliente — além do teto a predição não é realizada, e isso é degradação declarada em vez de recusa.

Fluxo do usuário

Um tiro disparado sob latência, julgado justo na linha do tempo do atirador e confirmado nas duas telas. O projétil e o bloco de Stats da vítima são Entity Presets — Declarations em Entities, não módulos que você monta.

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

Predizer o próprio movimento

Esta é a máquina do próprio cliente, e é o único dos três mecanismos de predição cujos erros são baratos. Você age sobre a sua própria entrada antes que o servidor tenha respondido, o servidor responde, e onde os dois discordam o seu cliente se corrige. Um erro aqui custa uma correção visual pequena — que é exatamente por que é seguro ser agressivo com ele.

Predição é uma repetição das regras declaradas, não uma segunda cópia

O seu cliente não roda uma implementação paralela do seu movimento. Ele roda o mesmo modelo declarado que a plataforma roda — o modelo pertence a Locomotion, e a predição apenas o aplica mais cedo. É essa toda a razão de os dois lados concordarem a maior parte do tempo: há um conjunto de regras, aplicado duas vezes.

O que quer dizer que não há operação "predizer" a chamar nem operação "corrigir" tampouco. A predição acontece porque o aspecto foi declarado previsível, não porque você invocou algo.

O que prediz é declarado por aspecto

A previsibilidade é uma Declaration no aspecto da Entity, e deliberadamente não é um interruptor global:

  • Um aspecto que o cliente consegue computar — posição sob a sua própria entrada — pode ser predito.
  • Um aspecto que a autoridade muda por regras que o cliente não tem não pode ser predito. Se o cliente não consegue derivá-lo, chutar produz um rollback que o jogador lê como o jogo mentindo.

Essa linha é onde você decide o que pode piscar e o que precisa estar certo de primeira.

O protocolo de correção, e os dois números que o moldam

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

O estado autoritativo chega carregando o número da última entrada que ele aplicou, então o seu cliente sabe exatamente quanto do próprio buffer ainda não foi confirmado. A partir daí:

  1. Aceite o estado autoritativo.
  2. Reproduza as entradas armazenadas que vieram depois daquela que ele reconhece.
  3. Reconcilie o resultado com o que você já estava mostrando.

Dois números declarados decidem como isso se sente. O limiar de divergência: abaixo dele a correção é suavizada, acima dele o seu cliente salta e reproduz. E o limite do buffer de entrada não confirmada: transbordar não é indefinido — a degradação é declarada e observável, então um cliente numa conexão ruim sabe que parou de predizer em vez de derivar em silêncio.

A divergência é observável ao cliente que a teve, e apenas a esse cliente. Você consegue saber que a sua predição foi corrigida e por quanto — útil para ajuste, e para mostrar ao jogador um indicador honesto de conexão. Você não consegue ler a divergência de outra pessoa: o tamanho de uma predição errada é informação sobre a conexão dela, não sobre o jogo. A correção é do lado do cliente, porque o estado da autoridade é o que já estava sendo mostrado a todos os outros.

Se você está chegando de outro lugar

  • O Mover 2.0 do Unreal. A forma é familiar: entradas carimbadas por tick, um modelo de movimento, correções da autoridade. A diferença é onde o modelo mora — aqui você o declara e a plataforma o simula, então não há um componente de movimento nosso para você herdar ou substituir.
  • Netcode de rollback e replay, como no Photon Fusion. Reproduzir as suas próprias entradas não confirmadas depois de uma correção é o mesmo mecanismo, e ele está aqui por inteiro. O que deliberadamente não está aqui é reexecutar o mundo depois do fato — veja Compensação de lag para o que acontece em vez disso, e por quê.

O que isto não cobre

As Entities de outros jogadores não são preditas, são exibidas — isso é Mostrar outros jogadores. Julgar um tiro na linha do tempo do atirador é um mecanismo de servidor e mora em Compensação de lag. E nenhum dos três se aplica quando o modo de autoridade da Room é externo: então o Tick pertence a quem o conduz, e a predição também.

The live game

Mostrar outros jogadores

Ninguém prediz outros jogadores — eles são exibidos. Você recebe o estado deles em intervalos e tem de desenhar algo no meio. Um erro aqui não custa a vida de ninguém; custa um solavanco visível, que é por que ele ganha Declarations próprias em vez de compartilhar as da predição.

O modo de exibição é declarado, não adivinhado

Para Entities que não são suas, a Room declara como preencher o intervalo entre os estados que chegam: interpolar entre os estados que você tem, ou extrapolar além do mais recente. Isso é uma Declaration na Entity, então a resposta é a mesma em todo cliente e não deriva conforme quem implementou o renderizador.

O atraso de interpolação também é declarado. Mostrar outros jogadores de forma suave quer dizer mostrá-los ligeiramente atrasados, por uma quantidade declarada. Nomear o número é o ponto: um atraso não declarado é um relatório de bug que você não consegue reproduzir, e um declarado é uma decisão de design que você pode ajustar ao seu gênero.

A extrapolação para em vez de inventar

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

A janela de extrapolação é declarada, e passada ela a Entity deixa de ser mostrada em movimento em vez de seguir num palpite. Extrapolar indefinidamente põe o jogador atirando num alvo que nunca esteve ali, e o jogador não tem como perceber — um congelamento visível é a falha da qual se volta.

Por que isto é separado de predizer o próprio

Os três mecanismos de predição têm autoridades diferentes e modos de falhar diferentes, e uma palavra para os três quer dizer que ajustar um muda os outros dois em silêncio.

MecanismoRoda emQuando erra
predizer o próprioo clienteuma correção, reproduzida e suavizada
mostrar outros jogadoreso clienteum solavanco visível
compensação de lago servidoralguém morre injustamente

Que é também por que existe um preset observer que carrega este mecanismo e mais nada: um espectador não tem entrada própria a predizer, então dar-lhe configurações de predição seria configurar algo que ele não faz.

The live game

Compensação de lag

Este é o mecanismo do servidor, e o único dos três cujos erros matam alguém. Quando ele decide errado, um jogador morre injustamente — e a favor de quem tem a conexão pior. Tudo nesta página é moldado por essa assimetria.

A pergunta que ele responde é estreita: o que o atirador de fato viu? Uma ação pode carregar um tempo de visão, o Tick para o qual o Actor estava olhando quando agiu, e a plataforma restaura as poses dos alvos naquele Tick para que o tiro seja julgado contra o que estava na tela dele.

O tempo de visão é uma alegação, não um fato

Ele chega do cliente, então é uma afirmação de quem chama e é tratado como tal. Duas consequências:

  • A janela de compensação é limitada, e fora dela a plataforma recusa. Ela não extrapola para ajudar. Uma recusa é uma decisão que você enxerga; uma extrapolação silenciosa é uma decisão que você não enxerga.
  • Ler o estado passado de um alvo continua obedecendo à visibilidade. Perguntar sobre um Tick histórico não é um jeito de contornar Visibility — o que você não podia ver então, não pode ler agora.

E "o acerto não contou" é um veredito, não um erro: uma resposta bem-sucedida com uma razão legível por máquina. O seu código fez uma pergunta legítima e recebeu um não legítimo.

O que sofre rollback é declarado, e não é tudo

Fazer rollback de tudo soa consistente e produz mortes duplas: dois jogadores atiram um no outro, ambos são rebobinados a um momento em que ambos estão vivos, ambos acertam. Fazer rollback de nada cancela a própria compensação de lag. A fronteira entre os dois é uma lista declarada, não a intuição de uma implementação.

A rebobinagem em si pertence aqui em vez de aos módulos rebobinados. O anel de histórico restaura as poses do Tick em disputa e então se faz a Collision a pergunta comum de sobreposição sobre essas poses — Collision não mantém histórico próprio e nada nele sabe o que é um Tick de visão. O anel em si é a trilha de histórico da Entity, não um segundo armazenamento.

A decisão é tomada sobre o passado; o efeito se aplica no presente

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

A compensação de lag responde a uma pergunta sobre o momento de visão do atirador. As consequências — dano, morte, a premiação — se aplicam ao estado atual. O que aconteceu entre o momento de visão e o momento da decisão não é cancelado nem recalculado.

Então isto é observável, e é intencional: um jogador pode conseguir disparar depois de ter sido morto pelo tiro rebobinado de outra pessoa. Cancelar isso significaria reproduzir o mundo em cima de uma rebobinagem que não promete reprodutibilidade — o que fabrica divergência em vez de removê-la.

Ressimulação no servidor está fora de escopo, deliberadamente. Recalcular consequências contra uma verdade nova precisa de um ponto de referência fixo que estado de ponto flutuante não nos dá. O que fica é tudo aquilo em que o módulo se apoia: um cliente reproduzindo as próprias entradas não confirmadas (Predizer o próprio movimento), e a compensação de lag como ler o passado para uma decisão. É assim que "favorecer o atirador" funciona na prática.

Se você está chegando de outro lugar

  • Compensação de lag favorecendo o atirador, como entregue na maioria dos shooters competitivos: o mesmo mecanismo, e esta página é ele.
  • Netcode de rollback completo. A rebobinagem está aqui; a reprodução do mundo depois dela não, e o parágrafo acima explica por quê. Se o seu design depende de consequências serem recalculadas depois do fato, essa dependência é o que levantar conosco cedo em vez de descobrir tarde.

Também vale saber

  • A implementação é sobrescrevível. Se o seu jogo precisa de outra regra de compensação, você pode substituir a nossa, e a substituição declara quais das Declarations ela honra.
  • Não há pontos de extensão no caminho de predição e correção. Eles rodam na taxa do Tick, e um Hook nesse laço seria um Hook que você não pode pagar.
  • Nada disso se aplica sob autoridade externa. A compensação de lag existe para Rooms que a nossa simulação conduz. Quando o Tick pertence ao servidor de jogo de um estúdio ou a um master-client, a compensação pertence a quem o conduz — veja Quem conduz o Tick.
The live game

Bots

Um bot entra como um jogador comum. Só o cérebro mora em outro lugar. Mesma sessão, mesma validação de entrada, mesmas regras, mesmo ACL. A Room não consegue notar a diferença, por desenho, então os bots exercitam as suas regras de jogo reais e o anticheat nunca precisa de uma exceção para bot.

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

Quando usar

  • Os seus lobbies precisam ser preenchidos fora do horário de pico — FillRoom completa partidas até uma cota e os bots cedem vagas conforme humanos chegam.
  • Os bots precisam jogar pelas regras reais — validação de entrada, ACL, Visibility — para que o anticheat nunca precise de uma exceção para bot.
  • Você traz um cérebro externo — uma política aprendida, um serviço — que entra por ConnectAsBot como qualquer jogador.
  • A Entity de um jogador desconectado precisa passar a um bot e voltar na reconexão, sem que a vaga ou a Prediction & Lag Comp percebam.
  • Pule quando o personagem nunca decide — um NPC de diálogo sem cérebro mora em World Objects.

Esta página é a metade da conexão. Levar um bot para dentro de uma Room, preencher um lobby até a cota, passar uma vaga entre um bot e um humano. Escrever a coisa que decide é a outra metade — Como escrever um cérebro, que especifica o encaixe em que um cérebro se pluga.

Quem faz o quê

ActorNesta página
bot-brainconecta como jogador; recebe percepção; envia comandos
room-ownerdeclara perfis, preenche Rooms até a cota, transfere bot/humano

De relance

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

Um cérebro externo (IA mais pesada, uma política aprendida, um serviço) conecta como qualquer jogador:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

O modelo

Um bot não introduz noção própria alguma — nem um participante, nem um canal de entrada, nem uma zona de visibilidade, nem comportamento. É uma credencial de Actor vestindo o que Rooms, Data & Subscriptions e Locomotion já declaram.

O que a Declaration de um bot carrega.

DeclaraO que é
thinking tickcom que frequência os cérebros são consultados, e não é o Tick de simulação: os cérebros rodam fora, e uma chamada de rede a cada Tick é inviável
direction of the brainsonde eles executam — uma função de nuvem, o backend do estúdio, o servidor de jogo dele. Qual deles não faz parte do contrato, e mudar entre eles não é mudança quebrante
actor presetos direitos do bot, como um preset de Actor comum
visibility of the bot markerse os participantes são informados. O marcador em si sempre existe e a plataforma sempre o observa; se os jogadores o veem é Declaration do tipo de Room, porque em alguns mercados revelar um adversário de IA é obrigação e em outros é escolha de produto
behaviour when the brains are unavailableum de três, sem padrão: do nothing · leave the room · fall back to built-in default behaviour
roster fillingdeclarado pelo tipo de Room — quantos, sob que condição, até que momento. Matchmaking nada sabe de bots: ele casa Actors, e não decide com quem completar

O que vale para todo bot.

SempreO que é
an actor, not a playerele segura uma credencial de Actor mas não tem provedor de login, nem vínculos, nem sessões
economic ownershipnão existe — sem entitlements, sem compras, sem entradas em Leaderboards — do contrário os bots acabam nas classificações e na economia
perceptioné de jogador: a mesma zona de visibilidade, o mesmo predicado, o mesmo limite de contagem de objetos. Um bot e um jogador na mesma posição recebem o mesmo conjunto de objetos, então um bot não consegue ver através de paredes mais do que um jogador consegue
wider perceptioné um preset de Actor, não propriedade de bot: um modo de depuração ou de "treinador onisciente" é declarado como preset com predicado mais largo
between thoughtsvale o último comando, e o destino dele é o que o tipo de movimento já declara para entrada obsoleta — um bot cujo cérebro está pensando é o mesmo caso de um jogador cuja rede caiu
room capacityconta o bot: ele ocupa uma vaga como qualquer outro

Erros

  • A Room não aceita bots é conflito, e repetir não vai ajudar.
  • O limite de bots está esgotado é conflito, não forbidden — a permissão de introduzir um está lá; a Room está cheia. Vale repetir quando a Room vagar.
  • Um comando para um bot vindo de um Actor sem a permissão responde forbidden, e repetir é inútil.
  • Os cérebros estarem indisponíveis não é erro — é um dos três comportamentos declarados acima. Se eles estavam lentos, fora do ar ou pensando é assunto da direção que os executa, e não faz parte do contrato; o que é observável é o mesmo que é observável para qualquer participante.
  • Declarado no deploy, recusado no deploy: um bot nomeado como dono de uma entrada de Leaderboard, e um thinking tick ausente, são ambos falhas de validação no momento do deploy em vez de surpresas numa Room ao vivo.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Bots numa Room — introduzir é recusado como conflito; bots existentes nunca são removidos para abrir espaço.
  • Bots por projeto — o mesmo conflito.
  • O thinking tick por baixo — uma Declaration mais rápida que o piso é recusada no momento do deploy, porque uma chamada de rede por Tick é inviável.
  • A taxa de comandos para um bot — limite de taxa com tempo.
  • O prazo para uma resposta dos cérebros — ao expirar, vale o comportamento declarado de indisponibilidade.

Fluxo do usuário

Um host completa o lobby até a cota, um cérebro externo assume uma das vagas, e a Room roda sobre as regras reais o tempo todo.

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

Como escrever um cérebro

Um cérebro é código comum que responde a uma pergunta: o que este bot faz em seguida. Ele roda onde você quiser — uma função de nuvem, o seu próprio serviço, um cliente sem interface — e conversa com a Room pela mesma superfície que o cliente de um jogador humano usa. Esta página especifica o encaixe em que ele se pluga: o que um cérebro recebe, o que pode devolver e quando. Bots cobre a outra metade: levar um bot para dentro de uma Room.

O que está resolvido, e contra o que você pode construir hoje

A plataforma não entrega IA de jogo. Sem árvores de comportamento, sem sistema de utilidade, sem cérebro de navegação. Isso não é uma lacuna esperando ser preenchida — é a fronteira. As decisões são suas, e o trabalho do módulo é tornar as suas decisões indistinguíveis das de um jogador.

Um cérebro não é um Hook. Um Hook envolve um passo nosso. Um cérebro não é um passo nosso de forma alguma: ele roda fora da Room, no ritmo dele, e a plataforma não se importa de qual lado a conexão foi aberta. É por isso que um cérebro pode ser uma função de nuvem, um serviço que você hospeda ou um cliente sem interface — e por que nenhum desses é mais nativo que os outros.

O encaixe é percepção para dentro, comandos para fora, e os dois lados são deliberadamente os de um jogador:

O que é
percepçãoexatamente o que um jogador naquela vaga receberia — os mesmos Deltas, pelas mesmas regras de Visibility. Um bot não consegue ver através de paredes mais do que um jogador consegue.
comandosexatamente o que um jogador naquela vaga enviaria. Canal de entrada privilegiado algum existe.

Se um jogo genuinamente precisa de um bot que enxergue mais — um modo de depuração, um modo de treino — isso é um alargamento declarado, não um efeito colateral de ser bot.

O thinking tick é declarado, e não é o Tick de simulação. Os cérebros estão fora, então pensam na cadência deles. Entre dois pensamentos vale o último comando — que é a coisa mais importante a considerar no desenho, porque quer dizer que um cérebro que pensa devagar não produz um bot parado, produz um bot que continua fazendo a última coisa que decidiu.

A saída dos cérebros tem comportamento declarado, e não há padrão. Você diz o que acontece quando o cérebro para de responder, por tipo de Room. "Cérebros indisponíveis" é um Event retido, então um assinante tardio fica sabendo da situação atual em vez de apenas de mudanças futuras.

O que um bot deliberadamente não pode ser

Vale ler antes de desenhar em torno disso, porque são recusas em vez de omissões.

  • Um bot não é um jogador, e não é dono de entitlements, compras ou registros de Leaderboard. Um bot que pudesse segurá-los seria um jeito de fabricá-los.
  • O sinalizador de bot sempre existe e é sempre observável pela plataforma. Se o seu jogo o mostra aos jogadores é decisão sua; se ele existe, não é.
  • O módulo não guarda histórico do que um bot decidiu nem por quê. Isso é assunto seu, na sua telemetria — não vamos nos tornar o lugar onde o raciocínio da sua IA fica armazenado.
Services around the game

Auth & Players

O sign-in é um passo sobrescrevível, não uma caixa-preta. Provedores, sessões, vinculação de identidades, banimentos. Todo ponto do fluxo — antes e depois do sign-in, antes e depois de um vínculo, antes e depois de uma fusão, numa mudança de status — é um ponto de extensão declarado com um tipo declarado: um portão que pode recusar o passo, ou um observador que não pode.

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Jogadores precisam entrar — dispositivo, e-mail, Apple, Google, Steam ou customizado — com criar-no-primeiro-sign-in como uma flag, não um segundo fluxo.
  • Uma conta de convidado precisa ser promovida depois — Link acrescenta Steam com o progresso intacto, e fusões reconciliam duas contas num jogador.
  • A política precisa rodar onde não pode ser pulada — um portão de região antes do sign-in, um pacote inicial depois do sign-in que criou o jogador.
  • A moderação precisa de dentes — revogar sessões, suspender, banir dispositivo, com um Event banned que todo sistema vivo ouve de uma vez.
  • Contexto declarado (região, plataforma, build) precisa alcançar todo Hook posterior sem que cada um releia o jogador para descobri-lo.
  • Não há nada mais leve para onde pular — todo outro módulo nomeia quem o chama por meio deste, e auth não pode ser desligado enquanto algum deles precisar de um Actor jogador: o configurador de módulos recusa, e nomeia os dependentes.

Quem faz o quê

ActorNesta página
playerentra, vincula ou desvincula identidades, atualiza credenciais, sai
moderatorrevoga sessões; bane, suspende ou restaura jogadores
backend-servicefiltra o sign-in por região; semeia as primeiras linhas de um jogador novo; lê e revoga sessões

De relance

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

Cada ponto é customizado onde é declarado; as formas que um handler pode assumir estão reunidas em 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.

A forma declarada é o que o painel desenha: todo ponto mostra os handlers dele, o tipo deles e a ordem resolvida. O tipo é a parte com dentes:

TipoQuando o próprio handler falhaNa recusa
um portãoo passo é recusado — uma verificação de região inalcançável não é uma verificação de região aprovadaum código do catálogo da plataforma mais uma razão humana. Quem chama ramifica pelo código; o texto da razão é livre para mudar e ser traduzido
um observadoro passo continua feito, então um pacote inicial que não chegou custa um baú, não o sign-inele não pode recusar

O que handler algum pode fazer é decidir quem entrou. Um portão responde sim ou não sobre uma identidade que a plataforma já estabeleceu; ele não nomeia o jogador, não entrega uma identidade e não substitui a confirmação do provedor. Essa linha é a diferença entre sign-in sobrescrevível e sign-in pulável.

O modelo

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

Um jogador é um portador de identidade, não uma linha no seu schema, e o identificador dele é estável e nunca reutilizado — inclusive numa fusão: o id de um jogador fundido continua resolvendo em vez de virar referência pendurada. Um vínculo é um trio: provedor · sujeito externo · jogador.

SempreO que é
provider + subjecté único, e essa unicidade é fonte de conflito, não de proibição — a resposta a "esta conta já está tomada" é escolher uma fusão, não ouvir não
at most one link per provider per playeruma segunda conta do mesmo provedor é conflito
an external subjectnunca é o identificador de um jogador: ele pertence ao provedor, e usá-lo como nosso amarraria os nossos ids aos deles
identity kind and access statussão eixos diferentes: anonymous versus registered é um; active / suspended / banned é outro. Confundi-los torna "anônimo banido" ou "registrado suspenso" inexprimíveis
a session and a credentialsão coisas diferentes: uma sessão é o registro; uma credencial é o que você apresenta. Revogar uma sessão invalida todas as credenciais dela e fecha as assinaturas abertas dela
many simultaneous sessionscada uma revogada independentemente
a credential's claimssão contexto declarado — região, locale — e apenas contexto. Um claim nunca carrega autoridade
a device fingerprintnão é identidade: nunca é motivo para admitir, apenas motivo para recusar, e é armazenado e comparado de forma irreversível

As três máquinas.

DeEstados
o tipo de identidadeanonymous → registered, e a transição é de mão única
o status de acessoactive ⇄ suspended, e active → banned → active para um desbanimento
o jogadoralive → merged, onde merged é terminal: um jogador fundido não entra de novo

O que o consumidor declara.

DeclaraO que é
sign-in policyse o sign-in anônimo é permitido, e o resto das regras em torno de entrar. Declarada como atributo no ponto de montagem do módulo — não um arquivo de configuração ao lado do código, e não construída em runtime
default roleo conjunto que um jogador novo carrega no primeiro sign-in. Não há padrão para o padrão: não declare nada e jogadores novos chegam sem papel algum, o que é uma Declaration legítima em vez de uma omissão
session policyo que acontece quando o teto de sessões simultâneas é alcançado — descartar a mais antiga com um Event, ou recusar a nova. Sem padrão
deletion policycomo a exclusão de um jogador alcança os dados que o referenciam

Onde um provedor é configurado. No plano do operador, não em código — uma credencial de loja não pertence a um repositório. O que é declarado alcança o console administrativo para leitura.

O que conceder um papel faz. Papéis não são assunto só de operador: a superfície carrega grant e revoke para um jogador, então um jogo pode promover o oficial de uma guilda ou entregar ao anfitrião de um torneio os poderes dele a partir do código próprio.

SempreO que é
it is not self-promotionconceder exige o átomo de permissão declarado para isso, e um Actor sem esse átomo recebe um forbidden simples em vez de um no-op silencioso
granting is idempotentconceder um papel que o jogador já segura é sucesso, não conflito: o estado é o conjunto de papéis, não o histórico de chamadas, então diferentemente do sign-in esta operação não precisa de chave de idempotência
revoking is not instante não fingimos que é. Ela tem efeito sem reemitir a credencial, e é observável no máximo até o limite declarado de obsolescência do cache de direitos — então código que concede um papel e imediatamente o confere num cliente conectado precisa ser desenhado em torno dessa janela
the default roleé declarado por projeto: o conjunto que um jogador novo carrega no primeiro sign-in. Não há padrão para o padrão — não declare nada e jogadores novos chegam sem papel algum, o que é Declaration legítima em vez de omissão

De que os papéis são feitos e o que eles destravam é Access & Roles.

Erros

  • Sem credencial, ou com uma expirada, responde not authenticated e uma atualização resolve. Uma credencial revogada responde igual mas uma atualização não resolve: só um novo sign-in.
  • Uma credencial rotacionada apresentada de novo é conflito — é isso que torna a rotação detectável em vez de silenciosamente tolerada.
  • Um jogador banido ou suspenso, e uma impressão digital banida, respondem forbidden, e repetir é inútil.
  • O par provider + subject está tomado é conflito, repetível depois de escolher uma fusão; uma segunda conta do mesmo provedor é um conflito que repetir não muda.
  • Desvincular o último método de entrada é recusa de validação: deixaria uma conta que ninguém alcança.
  • Fundir um jogador já fundido é conflito — merged é terminal.
  • O provedor estar indisponível responde unavailable e vale repetir com backoff; o provedor rejeitar a credencial responde not authenticated e vale uma repetição, não um laço. Colapsar os dois faria clientes martelarem um provedor que já disse não.
  • Taxa de tentativas de sign-in excedida responde na categoria de limite de taxa, com prazo.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Sessões simultâneas por jogador — pela política declarada: descarte da mais antiga com um Event, ou recusa da nova. Não há padrão.
  • Tentativas de sign-in por período, e tentativas de vincular um par tomado — limite de taxa com prazo, e o contador de tentativas fica no histórico.
  • Vínculos por jogador — vincular outro provedor é recusado como conflito.
  • O tempo de vida de uma credencial — not authenticated, repetível por uma atualização. O tempo de vida de uma credencial de atualização — só um novo sign-in.
  • Retenção de um jogador anônimo sem sign-ins — exclusão pela política declarada, com um Event. A política é declarada explicitamente; não há padrão.
  • Entradas na lista de banimento por impressão digital — um acréscimo é recusado, e entradas antigas nunca são descartadas em silêncio.

Fluxo do usuário

Uma conta de convidado no primeiro lançamento, promovida a Steam depois com o progresso intacto.

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Profile

Um Profile é uma visão, e a plataforma quase nada possui dele. O que a plataforma guarda sobre um jogador é o player_id e o perfil de sistema por trás dele — identidades, sessões, vínculos de provedor, tudo isso em Auth & Players. Tudo o que um jogador tem é a sua própria Entity, pertencente àquele jogador. Um Profile é o conjunto dessas Entities que o seu projeto declara, lido para um dono numa passagem.

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Uma tela precisa da fatia de um jogador numa chamada — o conjunto declarado se espalha pelas Entities dele em vez de o cliente costurar várias consultas.
  • Superfícies da plataforma precisam mostrar uma pessoa, não um identificador — um Leaderboard, uma fila de moderação e um chamado de suporte seguram um player_id e mais nada até o projeto nomear o registro que exibe um jogador.
  • Outro jogador precisa de um cartão — a mesma leitura contra outro dono, estreitada pelo predicado de linha e pela máscara de coluna já declarados em Access & Roles.
  • Um HUD precisa acompanhar estado próprio ao vivo — a leitura é uma seleção, e uma seleção assina.
  • Pule quando os dados não pertencem a um jogador — linhas compartilhadas e globais são uma seleção comum de Entity, sem dono de onde se espalhar.

Quem faz o quê

ActorNesta página
schema-authormarca Entities como pertencentes a jogador e declara quais delas formam o Profile
playerlê o próprio Profile; escritas vão para as próprias Entities
room-visitorlê o Profile de outro jogador, até onde o predicado e a máscara daquele jogador permitirem

De relance

A participação é declarada por Entity, não por campo. A Entity diz que pertence ao Profile; o que outro jogador pode ver dela é a máscara de coluna no papel que a lê (Access & Roles). Um atributo de visão no nível de campo seria uma segunda resposta à pergunta que o acesso já responde, e os dois divergiriam na primeira vez que alguém editasse um deles.

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

A leitura é uma seleção no escopo de um dono — a superfície de consulta de Entity com o dono fixado e a lista de Entities tirada da Declaration. Profile é o nome dessa leitura, não um módulo por trás dela: mesmos direitos, mesmos predicados, mesmos filtros, mesma assinatura, porque é a mesma operação.

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

O modelo

ConceitoO que é
player_idtoda a ideia que a plataforma tem de um jogador, mais o perfil de sistema por trás dele — Auth & Players
conjunto do Profileas Entities pertencentes a jogador que o projeto declara como Profile dele; declará-lo é opcional
seleção por donoa leitura: um dono entra, as linhas dele por todo o conjunto saem — os mesmos direitos, predicados, filtros e assinatura de qualquer seleção de Entity
leitura públicaaquela seleção contra outro dono, estreitada pelo predicado de linha e pela máscara de coluna do papel que lê (Access & Roles)

O que vale para toda leitura de Profile.

SempreO que é
there is no profile recordele não tem identificador próprio, nem Revision, nem histórico, nem ciclo de vida, porque é uma visão sobre linhas que têm os quatro
writes go where the data livesaltere a linha progress, e toda leitura de Profile que a inclui enxerga o valor novo na passagem seguinte
there is no public writeuma visão não tem onde escrever, e estado compartilhado gravável passa por código de servidor
declaring the set is optionale não declará-lo não é o mesmo que declarar um vazio: um projeto sem conjunto de Profile não tem leitura de Profile alguma e a chamada é recusada como indisponível, enquanto um resultado vazio diria que o jogador tem um Profile e que ele por acaso está em branco
ownership is a predicatenão uma coluna que a plataforma acrescenta (Access & Roles): owner == caller.player é uma instância do mecanismo, "um dos participantes" é outra
a derived field belongs to a hookum observador pós-mudança é o que carimba Progress.Title quando Level cruza um limiar. Ele segue a escrita; não pode recusá-la

Erros

  • Um projeto sem conjunto de Profile declarado não tem leitura de Profile, e a chamada é recusada como unavailable — não respondida com resultado vazio, que diria que o jogador tem um Profile e que ele por acaso está em branco.
  • Uma linha que um predicado esconde responde not found, e uma que não existe também: uma leitura pública nunca vira um jeito de descobrir o que existe mas não é visível.
  • Um campo fora da máscara do papel que lê está ausente da resposta, não presente e vazio.
  • Não existe escrita pública. Uma visão não tem onde escrever, então estado compartilhado gravável passa por código de servidor em vez de por esta superfície.
  • Uma escrita em nome de um jogador que não nomeia o jogador é recusa de validação.
  • Declarar o conjunto de Profile é um ato de esquema — fn ou adm. Uma chave de jogador que tente isso recebe forbidden, e o push é recusado inteiro em vez de declarado pela metade.

Limites

Cada teto nomeia o comportamento na fronteira; os números chegam com o capítulo de limites da plataforma.

  • O tamanho de página da seleção por dono — cortado no teto com o sinalizador "há mais" ainda verdadeiro; devolver menos sem o sinalizador é proibido.
  • O tamanho de um conjunto incluído por linha — cortado pela mesma regra, com o sinalizador na inclusão.
  • A taxa de mudanças numa instância — recusa por limite de taxa com prazo; a leitura de Profile é uma seleção como qualquer outra e herda os tetos de Entity em vez de declarar os próprios.

Fluxo do usuário

Do desenho do lobby ao nível virando: uma escrita no servidor alcança uma tela assinante sem que a tela pergunte de novo.

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Social

Um conceito novo, e todo o resto é construído com o que você já tem. Uma relação entre dois Actors, com estado próprio e um iniciador — é só isso que este módulo acrescenta. Um clã, uma guilda ou um squad é um Group com uma camada de relação por cima, não um segundo tipo de coisa; e o bloqueio, de que vários módulos precisam, mora aqui para que um só lugar seja dono dele.

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

Quando usar

  • Jogadores precisam uns dos outros pelo nome — amigos, seguidores, listas de bloqueio.
  • Um clã ou guilda precisa de uma porta — um convite do grupo, um pedido de entrada de um Actor, e uma decisão sobre qualquer um dos dois.
  • Uma lista de amigos tem de mostrar quem está online — a presença é derivada das sessões, e quem pode vê-la é um predicado que você declara.
  • Outro módulo precisa saber que alguém está bloqueado — ele lê esse estado daqui em vez de manter o próprio.
  • Pule quando a coisa é um conjunto de Actors em vez de um par com estado: isso é um Group, e um Group por par significaria milhões de Groups de duas pessoas, cada um com ciclo de vida e regras de entrada próprios.

Quem faz o quê

ActorPodeNão pode
playerpropor uma relação ou seguir; aceitar, recusar ou retirar; romper uma mútua; bloquear e desbloquear; ler as próprias relações e a presença de Actors relacionados; assinar mudanças; enviar um pedido de entradaler a lista de relações de qualquer outra pessoa, sob qualquer relação de participante que seja
moderatordecidir sobre convites e pedidos de entrada onde segurar o átomo de administração de associaçãodecidir sobre uma intenção para a qual não tem permissão — isso responde forbidden

O modelo

O que a Declaration de uma relação carrega.

DeclaraO que é
kindsymmetric — o par precisa de concordância dos dois lados, e a máquina de estados abaixo é sobre ela; ou one-sided — seguir, cujo único estado é active. Unicidade por par e idempotência de uma proposta valem para os dois
re-invitation ruledepois de uma recusa: proibida · permitida depois de um período declarado · permitida de imediato. Declarada, porque "perguntar de novo" é decisão de produto
presence visibilityum predicado — para todos · apenas para mutuamente conectados · para ninguém. Não há padrão
joining mode (no tipo de Group)aberto · por pedido com decisão · apenas por convite
retention of declined and brokendepois do período declarado a relação é removida, e reconvidar volta a ser possível independentemente da regra de reconvite

Os estados de uma relação simétrica.

EstadoSignificado
proposedo iniciador propôs e o outro lado não respondeu
mutualos dois lados concordam
declinedo outro lado recusou. A relação é mantida, porque a regra de reconvite precisa saber
brokenum lado saiu de uma relação mútua
blockedum lado bloqueou o outro

O que vale para toda relação.

SempreO que é
one entity per pairnão dois registros espelhados. "A propôs a B" e "B recebeu proposta de A" são um fato lido de dois lados
an initiatoré declarado: quem propôs, de que tanto a exibição quanto a regra de reconvite precisam
blocked dominatesdele não há transição para proposed ou mutual
a blocké assimétrico no controle e simétrico no efeito: só quem o pôs pode retirá-lo, e ele age nos dois sentidos
a refusal on a blocknão o revela: a operação responde not found, então um Actor bloqueado não consegue descobrir o bloqueio sondando
the block statepertence aqui e é consumido em outros lugares: Messaging e outros o leem; nenhum deles o altera, e nenhum guarda cópia
presenceé derivada das sessões: não escrita por ninguém, e o predicado de visibilidade é aplicado por solicitante em vez de uma vez por Actor
a deferred intentnão ocupa vaga: um convite ou um pedido de entrada nunca conta para a capacidade do grupo — do contrário cem pedidos esgotam um clã de cinquenta e ninguém consegue entrar
a groupmantém um administrador: pelo menos um Actor precisa segurar o átomo de administração de associação, e o último não pode simplesmente sair: um clã cujo último administrador foi embora nunca mais poderia admitir ninguém
no intra-group roles"oficial de clã" é um Actor segurando um átomo, não uma patente guardada numa lista
an import never overwritesrelações trazidas de um provedor de login são aditivas: alguém bloqueado não vira amigo porque um provedor disse

Erros

  • Já mútua é conflito; não há o que propor.
  • Uma proposta a si mesmo é recusa de validação.
  • Um dos lados bloqueou responde not found — não forbidden, porque uma recusa que os distinguisse revelaria o bloqueio. Repetir é inútil.
  • Um reconvite antes do prazo é conflito, que vale repetir depois dele.
  • Um limite esgotado — relações, intenções — é conflito, não forbidden: a permissão está lá, o espaço não. Repita quando um vagar, ou quando as intenções existentes tiverem sido decididas.
  • Uma intenção expirada é conflito: crie uma nova em vez de repetir a antiga.
  • A saída do último administrador de um grupo é conflito até que a permissão tenha sido repassada.
  • Decidir sobre a intenção de outra pessoa sem a permissão responde forbidden, e repetir é inútil.
  • Uma importação de um provedor não conectado responde unavailable — repita com backoff.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Relações mútuas por Actor — uma proposta é recusada como conflito; as existentes nunca são rompidas para abrir espaço.
  • Relações unilaterais por Actor — uma nova é recusada; as existentes ficam.
  • Propostas enviadas — uma nova é recusada, e não há descarte: um convite descartado seria indistinguível de um recusado.
  • Bloqueios por Actor — acrescentar um é recusado como conflito, e bloqueios antigos não são descartados; alguém silenciosamente desbloqueado volta a escrever e ninguém sabe por quê.
  • O tempo de vida de uma intenção — expired, com um Event.
  • A taxa de propostas por Actor — limite de taxa com tempo.
  • A taxa de mudanças de presença no fluxo — limitada pela taxa de atualização em vez de por descarte de mudanças.
  • Retenção de relações recusadas e rompidas — remoção pelo período declarado.

Fluxo do usuário

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Messaging

Rooms, Groups, jogadores: um modelo de endereçamento para chat e notificações. Mensagens chegam numa conversa; conversas são Channels com histórico, moderação e entrega fora de banda por cima.

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Jogadores conversam — chat de Room, canais de guilda, mensagens diretas — sobre o endereçamento que você já tem: Room, Group, jogador.
  • Jogadores offline ainda precisam ouvir — notificações com template e agendáveis entregam fora de banda por push.
  • A moderação precisa rodar antes da entrega — um Hook de pré-envio filtra ou rejeita, e mute/bloqueio é imposto pela plataforma em toda parte.
  • Jogadores que voltam precisam se atualizar — History(take: 50) pagina a conversa no lançamento seguinte.
  • Pule quando o payload é estado de jogo, não conversa — campos sincronizados em Data & Subscriptions e Channels de Core já espalham isso.

Quem faz o quê

ActorNesta página
playerenvia e recebe mensagens; lê histórico; silencia ou bloqueia
moderatorfiltra, redige e bane termos
backend-serviceenvia ou agenda notificações com template

De relance

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

Uma mensagem estruturada é um Event declarado, e a conversa então a carrega pelo nome — nenhuma classe de payload a construir no ponto de chamada:

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

A mensagem declarada chega tipada na mesma assinatura, então um cliente que conhece RallyCall recebe campos em vez de um blob.

Notificações são fora de banda, com template e agendáveis — e são enviadas com autoridade fn ou adm, nunca de uma sessão de jogador:

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

Moderação como Hooks, o mesmo contrato de sempre:

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

O modelo

O que um tipo de conversa declara.

DeclaraO que é
the groupa lista dela — um participante é um Actor, exatamente como em Groups
binding to a lifetimeopcionalmente o de outra Entity, para que um chat de Room desapareça com a Room dele

Onde passa a linha entre o envelope e o payload.

ParteDe quem é
envelopeda plataforma: o autor, a conversa, o momento pelo relógio declarado
payloaddo estúdio, declarado como um tipo de mensagem com campos tipados, e aparecendo como superfície de envio própria em vez de como sacola sem tipo

Três coisas de que este módulo é dono e um Event comum não é — a ordem dentro de uma conversa, o período de retenção e o objeto da moderação. É por isso que chat não é "um Event com histórico": a ordenação para um jogador, o histórico dele e a moderação são assunto da plataforma aqui e não estão em Events.

As três máquinas.

DeEstados
uma conversacreated → active → closed
uma mensagemsent → published | rejected by the filter, e então editada ou apagada, observavelmente
uma notificaçãocreated → queued → delivered | expired

O que vale para toda mensagem.

SempreO que é
order within a conversationé estável e declarada. Ordem entre conversas não é prometida
editing and deletingsão observáveis: uma mensagem nunca some em silêncio — do contrário o histórico de um cliente e o do servidor divergem sem que ninguém saiba
historysão as próprias mensagens: com período de retenção declarado, lidas em páginas por cursor a partir de uma posição
retention outlives the complaint windowo período não é menor que o tempo permitido para tratar uma denúncia: uma denúncia chega depois da mensagem, e uma mensagem que não existe mais não deixa nada a tratar
read stateé uma posição, não um sinalizador: uma posição por Actor por conversa, e marcar como lido é monotônico — a posição nunca diminui, então uma chamada repetida não desfaz progresso. A contagem de não lidas é uma derivada dessa posição em vez de um contador próprio
sendingé idempotente por chave: duas chamadas são duas falas de um diálogo, então a chave é o que torna uma repetição segura
blockingé um predicado de entrega, não uma recusa de envio: quem envia não é avisado, porque uma recusa revelaria o bloqueio. O estado em si mora em Social
the sender composes the payloada plataforma não lê os dados do destinatário para preencher o seu texto. O locale do destinatário pode ser um claim de contexto declarado que viaja até o ponto de extensão, então substituição e tradução são trabalho do Hook — o único lugar que conhece tanto o destinatário quanto o locale dele
the delivery routenão faz parte do contrato: push, dentro do app, ou outra coisa é decisão de roteamento, não promessa
deliveryé observável dentro de limites declarados: "enfileirada" sempre; além disso até onde a rota conseguir reportar

Cada ponto de extensão nomeia o tipo que entrega ao Hook: a mensagem de saída antes da publicação, a mensagem publicada depois. Um filtro pode corrigir o conteúdo que lhe foi entregue — mascarar uma palavra é uma correção — mas nunca o remetente nem a conversa.

Erros

  • Uma conversa que não existe ou está escondida, e um Actor que não é participante, respondem ambos not found — então uma recusa nunca revela uma conversa da qual você não faz parte.
  • Uma conversa fechada é conflito.
  • Sem permissão para escrever neste tipo responde forbidden, e repetir é inútil.
  • Rejeição pelo filtro é veredito, não recusa. A chamada foi realizada, o conteúdo foi considerado, a decisão é negativa e a razão é um valor declarado — que é por que ela é distinguível de uma recusa por permissões, e por que o que fazer em seguida depende da razão.
  • O filtro estar indisponível responde unavailable e vale repetir com backoff — mas nada foi publicado nesse meio-tempo.
  • Um tipo de mensagem não declarado para esta conversa, e uma mensagem grande demais, são recusas de validação; conteúdo nunca é truncado em silêncio.
  • Taxa de envio excedida responde na categoria de limite de taxa, com prazo.
  • Editar a mensagem de outra pessoa responde forbidden.
  • Uma notificação além do vencimento dela é conflito: envie uma nova.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Tamanho da mensagem, e anexos com o tamanho deles — o envio é recusado como falha de validação, nunca truncado. Os arquivos em si são de Files & UGC.
  • A taxa de envio por Actor — limite de taxa com prazo.
  • A profundidade do histórico — passado o período, uma mensagem é descartada da retenção com um Event, em vez de desaparecer quieta.
  • Conversas por Actor — entrar em mais uma é recusado como conflito.
  • Notificações enfileiradas por Actor — uma nova é recusada, e descarte é proibido: uma notificação silenciosamente descartada é indistinguível de uma que nunca foi enviada.
  • O vencimento de uma notificação — expired, com um Event.
  • Participantes numa conversa é limite de Groups, e bloqueios por Actor é de Social — nenhum dos dois é repetido aqui.

Fluxo do usuário

Uma mensagem de convocação alcança a guilda inteira. Dois papéis dividem a entrega: o online-member, que está na conversa quando ela cai, e o offline-member, que recebe um push e lê a convocação no histórico no lançamento seguinte.

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Catalog & Commerce

Itens, preços, carteiras, vitrines, compras, entitlements. Integrações reais com lojas onde as plataformas permitem (Stripe, App Store, Google Play, Steam, Xbox); vitrines agendadas e direcionadas por audiência; e um fluxo de compra em que cada passo aceita Hook.

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Você vende coisas — por dinheiro real via Stripe, App Store, Google Play, Steam ou Xbox, ou por moeda de carteira.
  • Vitrines precisam resolver por jogador — agenda, audiência e preço computados no servidor, nunca a conta de elegibilidade no cliente.
  • Regras de precificação pertencem a um Hook testável — descontos, reprecificação e vetos rodam antes de qualquer cobrança.
  • Recibos precisam ser à prova de repetição, e um reembolso precisa revogar o entitlement pelos mesmos Events que a concessão usou.
  • Pule quando itens nunca são vendidos — embora recompensas ainda cheguem pelo único Grant do comércio com origem reward (os baús de ciclo de Leaderboards chegam assim), então mesmo um jogo sem loja mantém um livro de concessões auditável.

Quem faz o quê

ActorNesta página
playernavega vitrines, compra, gerencia a carteira, resgata códigos
sellerconfigura catálogo, preços e agendas de vitrine
backend-servicevalida recibos; reprecifica ou concede via Hooks de compra

De relance

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

O modelo

O que um item de catálogo declara.

DeclaraO que é
keyele é conteúdo autoral, endereçado por uma chave para que renomear em código seja renomear
kindconsumable — é gasto; ou durable — possuído uma vez
pricesum preço é uma quantia monetária: um inteiro na menor unidade monetária mais um código de moeda, nunca um float. Um item pode carregar vários — moeda de jogo e moeda real
external identifier per providerum espaço por provedor, declarado, porque uma loja conhece o item pelo id dela
what it points atopcionalmente uma Entity de qualquer tipo declarado, e comprar o item então concede a posse daquela Entity

O que a composição pode ser.

O que é
what is purchasableum item de catálogo, nunca uma Entity arbitrária: um preço sem ninguém atendendo a ele não é promessa, porque uma compra precisa de alguém que conceda o direito e responda pelo reembolso
two levels, no thirdum pacote é um item de catálogo feito de itens; uma vitrine é um conjunto de ofertas, e uma oferta aponta para um item e pode sobrepor o preço dele e o conteúdo de um pacote
a territorial priceé exprimido por uma vitrine em vez de no item

O que uma vitrine declara.

DeclaraO que é
offerso conjunto, cada uma apontando para um item
scheduleem tempo de relógio, sempre UTC: quando a janela abre e fecha
audienceum predicado, não uma lista de jogadores — então a audiência é uma regra que continua verdadeira em vez de um instantâneo

Uma vitrine em cuja audiência um jogador não se encaixa não existe para aquele jogador.

Os estados de um pedido.

DePara
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid ou grantedrefunded
SempreO que é
the priceé fixado no pedido no momento em que ele é criado, então uma mudança de preço depois não pode alterar o que foi acordado
awaiting paymenttem prazo declarado, declarado por provedor, porque eles diferem
grantingé separado do pagamento: paid e granted são estados diferentes: o dinheiro chegar e a coisa aparecer são dois fatos, e confundi-los esconde qual dos dois falhou
a refundé uma transição externa: chega sem pedido nosso, a qualquer momento, e o que acontece com o que foi concedido é declarado — há três respostas e nenhum padrão
an entitlementcarrega a origem dele — uma compra, um código promocional, uma recompensa, um presente — para que "de onde isto veio" seja respondível um ano depois
a consumable entitlementacumula: ele muda por incremento com chave de idempotência, nunca sobrescrevendo o que foi lido
ownershipé um predicado de dono: um entitlement pertence a um jogador pelo mesmo mecanismo de qualquer linha com dono
the catalogé declarado em código e alcança o painel sob o modo de posse seed por padrão: o código cria o que está ausente, e as edições de um designer sobrevivem ao push seguinte
provider secretsmoram no plano do operador, nunca na Declaration, e nunca num repositório
a provider's capabilitiessão declaradas: se ele sequer tem uma API utilizável, e o que ele consegue fazer — para que um catálogo não prometa um fluxo que a loja não consegue atender

Cada ponto de extensão nomeia o tipo que entrega ao Hook: uma intenção de compra antes da compra — jogador, oferta, provedor, preço — e a própria compra depois. Um Hook nunca recebe uma sacola sem tipo.

Erros

  • Fora da audiência responde not found, e repetir é inútil. Fora da agenda também responde not found, mas vale repetir quando a janela abrir.
  • O provedor está indisponível e o provedor recusou o pagamento são deliberadamente respostas diferentes: a primeira é unavailable e repetível com backoff, a segunda um conflito que repetir não resolve. Colapsá-las faria chamadores repetirem uma recusa para sempre.
  • Um recibo inválido é recusa de validação; um recibo já consumido por outro pedido ou outro jogador é conflito — é isso que torna a repetição inútil.
  • O preço mudou entre ler a vitrine e comprar é uma falha de pré-condição: releia e decida de novo, em vez de ser cobrado o preço novo em silêncio.
  • Moeda de jogo insuficiente é conflito, não forbidden — a permissão de comprar está lá, o saldo não. Vale repetir depois de recarregar.
  • Um entitlement durável já possuído é conflito.
  • A região ou a idade não permitem a compra responde forbidden, e repetir é inútil.
  • O prazo do pedido passou é conflito: crie um pedido novo.
  • O limite de gasto esgotado responde como conflito ou como limite de taxa conforme qual limite tenha sido, e diz quando o limite reseta.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • O tamanho do catálogo — publicar mais um item é recusado como conflito.
  • Vitrines por projeto — a criação é recusada.
  • Ofertas numa vitrine — um acréscimo é recusado; a vitrine nunca é truncada em silêncio.
  • O tempo de vida de um pedido aguardando pagamento — transição para expired, com um Event.
  • A taxa de tentativas de compra — limite de taxa com prazo.
  • O limite de gasto por período — um conflito que diz quando o limite reseta.
  • Retenção de pedidos — passado o período um pedido fica ilegível pelo período declarado em vez de sumir sem explicação.
  • Entitlements por jogador — uma concessão é recusada, e os já concedidos nunca são descartados.
  • A precisão de um preço não é limite, é tipo — um inteiro na menor unidade monetária.

Fluxo do usuário

A primeira compra de um jogador novo: a vitrine resolve, o preço cai pela metade, o item chega — e a venda alcança o funil de primeira compra que o operator lê em Analytics.

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Inventory

Tudo se integra aqui. Tiros debitam munição, drops caem aqui, abilities consultam aqui, o movimento é modificado por aqui — um conjunto de linhas com dono, com pilhas que incrementam e um teto por dono cujo comportamento na fronteira você escolhe.

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

Quando usar

  • Jogadores possuem coisas, e uma posse é uma linha com dono — lida por dono, limitada por dono, com o comportamento de transbordo declarado em vez de assumido.
  • Uma quantidade acumula — uma pilha muda por incremento com chave de idempotência, então um débito repetido não debita duas vezes.
  • Outros módulos gastam de um conjunto — tiros debitam munição, drops concedem loot, compras aparecem como linhas contra o entitlement delas.
  • Pule quando o número não pode ter dono — hp, xp e cooldowns pertencem a Stats.

Quem faz o quê

ActorNesta página
playerlê as próprias posses e gasta delas
backend-serviceconcede, incrementa e revoga em nome de um jogador, nomeando o jogador por quem age

De relance

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

Uma sessão de jogador roda as leituras, as movimentações e a verificação de saldo com as mesmas chamadas. Conceder, consumir e destruir não são dela: a plataforma as recusa como forbidden e nomeia o direito que falta a quem chamou, qualquer que seja o binding que fez a chamada.

O modelo

Um Inventory não introduz noção própria alguma. Ele é um preset — uma forma montada com o que Entity já dá, então tudo abaixo é uma Declaration de Entity em vez de um mecanismo desta página. Um preset que precisasse de um novo tipo de Declaration seria uma lacuna no contrato, não uma razão para estender o preset.

DeclaraO que é
um tipo com donoa posse pertence a um dono, e seleção por dono é operação da própria Entity
um ref para um item de catálogoa referência guarda o id do item e nunca o key dele, que é exatamente o que torna renomear uma chave seguro. A definição em si mora em Catalog & Commerce
um aspecto de pilha com incrementouma pilha muda por um delta em vez de sobrescrever o que foi lido. O incremento não é idempotente por natureza — dois incrementos são dois incrementos — então ele é obrigado a aceitar uma chave de idempotência, e o jeito de estabelecer o desfecho é uma leitura endereçada
um teto por dono com o comportamento de fronteira deleum de três, e não há padrão: refuse · redirect para um balde de dono declarado · discard with event

O que vale para toda posse.

SempreO que é
the cap has no defaultas três respostas a uma mochila cheia são três jogos diferentes: uma recusa perde o loot na frente do jogador, um redirecionamento é correio ou um armazém transbordando, um descarte é uma perda silenciosa, lícita só porque foi declarada e é observável. Nenhuma serve para as três, então a declaração escolhe
the owner is immutablenada troca de mãos editando um campo: uma posse se move como revogação mais uma nova concessão com origem declarada, e os dois fatos ficam no registro. Editar o dono apagaria a trilha, deixando "de onde veio isto" e "tiraram de mim" sem nada por trás do estado atual
a transfer between two playersé outra promessa: precisa de custódia e antifraude, e está fora desta versão
a rowexibe um entitlement em vez de ser uma segunda fonte dele — o que foi comprado mora em Catalog & Commerce, e a linha aqui o representa

Erros

  • A instância não existe, ou um predicado a esconde — a resposta é not found nos dois casos, então uma recusa nunca revela que algo existe mas não é seu.
  • Um campo não declarado no aspecto (aninhados incluídos), e um campo obrigatório sem valor, são recusas de validação nomeando o campo.
  • A versão não casou é falha de pré-condição, que vale repetir depois de reler.
  • Uma escrita em nome de um jogador que não nomeia o jogador é recusa de validação, não uma escrita silenciosa como outra pessoa.
  • Sem permissão para uma leitura ou uma escrita responde forbidden, com leitura e escrita distinguidas.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Instâncias por dono — pela regra declarada acima, e não há padrão.
  • O tamanho da instância armazenada — a escrita é recusada como conflito, e a recusa nomeia o campo culpado e o tamanho medido. O teto é alcançado por acumulação, então a aproximação dele é observável antes da escrita que falha.
  • A taxa de mudanças numa instância — recusa por limite de taxa com prazo.
  • O tamanho de página da seleção — a página é cortada no teto e o sinalizador "há mais" continua verdadeiro; devolver menos sem o sinalizador é proibido.

Fluxo do usuário

A munição de um tiro, do cast que a debita ao drop da caixa que a concede de volta. A ability, o projétil, o bloco de Stats da caixa e a DropTable dentro dela são Entity Presets — Declarations em Entities, não módulos próprios.

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Leaderboards

Toda mecânica, sistematizada. Não um catálogo de tipos de placar. Um modelo cujos eixos se compõem em todos eles: classificações diárias, placares de melhor volta, totais de guilda, temporadas, torneios.

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

Leia aquele bloco assim: quem age nesta página (actors), o que o módulo lhe entrega (provides), sobre quais módulos ele se apoia (builds-on), e onde ele pendura em relação à raiz — mounts: root quer dizer playserv.Leaderboards, não um espaço de nomes sob outro módulo (como os módulos montam).

Quando usar

  • Pontuações precisam classificar jogadores — classificações diárias, placares de melhor volta, totais de guilda — como um modelo declarado, não um sistema por placar.
  • Você precisa das leituras padrão — top-N, ao-meu-redor, uma lista nomeada de donos — sem modelagem de dados extra.
  • Ciclos precisam fechar no horário, arquivar (nunca apagar) e disparar um Hook de recompensa com a tabela final.
  • Pontuações suspeitas nunca podem entrar na tabela — um Hook de pré-envio valida, limita ou rejeita com razão tipada.
  • Um torneio é o mesmo placar com janela de inscrição, máximo de participantes e tentativas por ciclo.
  • Pule quando o número nunca é comparado entre jogadores — um contador pessoal ou um total de carreira é Data & Subscriptions comum. O módulo ordena resultados; nunca os computa, e não executa chave de eliminação.

Quem faz o quê

ActorNesta página
playerlê top-N/ao-meu-redor/posição própria, assina mudanças de posição
backend-serviceenvia resultados; corrige ou rejeita no Hook de pré-envio; concede recompensas quando um ciclo fecha
operatordeclara placares; fecha um ciclo antecipadamente, corrige registros (auditado), acompanha taxas de envio

De relance

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

A Declaration mora junto com o resto do seu schema — no projeto de servidor, ou no projeto de UE ou Unity — e playserv push a compila e a envia: o placar aparece no painel, vazio, com o próximo reset já agendado. Agendamentos são em UTC, então este placar fecha segunda 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") muda a hora. Horário local por jogador não é opção de reset — uma tabela não pode fechar em vinte e quatro momentos diferentes.

A chave de ordenação é uma lista, não uma pontuação mais um desempate. Os campos classificam na ordem em que você os numera, cada um com direção própria, e o último nível é da plataforma: em chaves iguais o envio mais antigo fica acima, então duas corridas idênticas nunca trocam de lugar entre duas leituras. Um campo fora da chave — Map aqui — é levado para exibição e nunca move uma linha.

Agg diz o que um segundo envio faz com o único registro que um dono tem no ciclo atual:

AggUm segundo envioIdempotente
Setsubstitui o registro pelos valores enviadossim
Bestsubstitui só quando os valores novos classificam mais alto pela chave de ordenaçãosim
Incrementsoma os valores enviados ao registro — abates, voltas, contribuição de guildanão — leve uma chave de idempotência
Decrementsubtrai-osnão — leve uma chave de idempotência

Um envio que não bate um registro Best não é erro: ele volta aceito, ordem inalterada. Increment e Decrement são os dois que uma chamada repetida aplicaria duas vezes, então eles aceitam a mesma chave de idempotência de toda outra escrita repetível.

Enviar é uma chamada, e neste placar ela vem do código do servidor porque a Declaration assim disse:

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

Três coisas naquela chamada valem ser lidas em separado:

Na chamadaO que é
PlayServ · playservo handle da função de nuvem e a instância de cliente que o SDK lhe entrega na inicialização. A mesma API, dois chamadores — em Go são ps e psv, e cada snippet usa o que o seu chamador tem
quem submetea função dona do resultado da partida. Em Tanks é o Hook on dispose da Room (Rooms), que roda com o estado final em mãos
playerIdo id de jogador da plataforma, de Auth & Players, nunca um nome que você escolheu: um Hook o lê do payload dele (e.By.PlayerId na lição), e um host de Room submete o id da vaga que possui

Os valores são os campos que a Declaration nomeou — um campo não declarado é recusado, não armazenado.

As leituras de que todo jogo precisa, e a assinatura que as mantém atuais:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) é uma janela por posição, não uma página: cinco linhas acima de você, cinco abaixo, mais a sua — onze linhas, aparadas simetricamente onde a tabela termina, então a posição 2 recebe uma janela mais curta dos dois lados em vez de uma deslocada. Top é paginado: devolve as primeiras N linhas e um cursor, e after: percorre o resto.

ForOwners é como um placar de amigos funciona. A plataforma não guarda grafo de amizade; você passa os donos que o seu jogo já tem — os membros de um Group, ou uma lista de ids dos seus próprios dados — e cada linha volta com a posição dela na tabela completa, não uma posição dentro da lista.

OnRankChanged entrega a posição do próprio jogador local e mais nada: um placar com cinquenta mil participantes não empurra cada rearranjo para cada cliente. O callback recebe a linha que mudou — posição, os campos classificados, os campos de exibição — e Cancel() encerra a assinatura. A posição em si é um instantâneo: duas leituras com um segundo de intervalo podem diferir enquanto envios caem, embora o seu próprio envio esteja sempre visível para a sua própria leitura seguinte.

O modelo

O que um placar declara.

EixoValoresComo você define
Donojogador · GroupOwner = Owner.Player — um placar de guilda é o mesmo placar com Owner.Group
Chave de ordenaçãoum ou mais campos declarados, cada um crescente ou decrescente[Rank(1, Sort.Descending)] int Score
Agregaçãoset · best · increment · decrementAgg = Aggregation.Best
Resetum agendamento em UTC; um ciclo expira, nunca apagaReset = Reset.Weekly(DayOfWeek.Monday)
Quem pode enviarsó o servidor (o padrão) · jogadoresSubmit = Submit.ServerOnly
Campos de exibiçãodeclarados e tipados; nunca parte da ordenação[Display] string Map
Lista de donosescolhida no momento da leitura, não declaradaForOwners("weekly-score", ids) — amigos, uma guilda, um lobby
Regras de torneiojanela de inscrição · máximo de participantes · tentativas por ciclo · exigir entradaRules = Tournament.Define(…), na tabela sob Tournaments

Não há eixo de escopo: um placar por região, por Room ou por temporada é um placar por chave, e a chave é o que o seu código referencia.

O que vale para todo placar.

SempreO que é
direction and operatorsão imutáveis depois da primeira escrita: mudá-los reclassificaria o histórico em silêncio; o jeito de mudar uma mecânica é uma geração nova, não uma edição
exactly one entry per owner per generationum segundo não é uma segunda linha
an entrynão é uma Entity: sem ciclo de vida próprio, sem máquina: ela é criada pelo primeiro envio e mudada pelo operador que o placar declarou
fields outside the order key never affect the ordereles são exibição, e é por isso que são declarados à parte
a generationexpira, não apaga: open → expired → evicted from retention, e gerações expiradas continuam legíveis pelo período de retenção declarado
the schedule transitioné observável por um Event, então um handler lê exatamente a tabela que fechou em vez da vazia que acabou de abrir
the default submitteré o servidor: quem pode enviar é declarado, e o padrão não é o jogador
a boardé conteúdo autoral: declarado em código, endereçado por um key, alcançando o console administrativo, sob o modo de posse seed para que as edições de agenda de um designer sobrevivam ao push seguinte
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

O que é um ciclo, e o que fechar um faz.

O que é
a resetfecha um ciclo em vez de apagá-lo
a closed cyclepara de aceitar envios e continua legível sob o rótulo dele — Top("weekly-score", 100, cycle: label), um parâmetro de leitura em vez de uma tarefa de exportação
the close eventcarrega esse rótulo, então um handler lê exatamente a tabela que fechou e não a vazia que acabou de abrir

Os dois Hooks de um placar, e os tipos deles diferem.

HookO que pode fazer
pre-submitum gatekeeper: a plataforma o chama e espera. Ele pode corrigir os valores enviados contra as suas próprias Entities, limitá-los, ou rejeitar com razão tipada, e se falhar o envio é recusado — fail-closed. Ele não pode mudar o dono do registro nem o placar dele: esses já estão reivindicados. Ele devolve um veredito — aceitar, aceitar um envio corrigido, ou rejeitar — e a rejeição alcança quem chamou como um problema tipado (Core), a mesma forma que toda recusa no SDK toma
cycle-closedum observer: disparado depois do fato, ele não pode vetar, e uma falha ali deixa o ciclo fechado
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

A recompensa é uma concessão de Catalog & Commerce em vez de uma mecânica deste módulo: chest.gold é um id de catálogo, e a origem reward é o que separa a concessão de uma compra — reembolsos, revogação e o Event de entitlement alterado funcionam nela exatamente como num item comprado.

Torneios. Um torneio é este placar mais restrições de participação — não há um segundo mecanismo nem uma Entity separada. As quatro restrições, com as unidades e o comportamento na fronteira delas:

RestriçãoDeclarada comoNa fronteira
janela de inscriçãoentryWindow: TimeSpan — por quanto tempo entrar continua aberto depois que o ciclo abreuma entrada depois que ela fecha é recusada; o ciclo ainda corre até o reset dele
máximo de participantesmaxEntrants: int — registros num cicloo participante 65 de 64 é recusado como conflito, e nada é descartado — um placar que largasse as piores linhas classificaria quem chegou primeiro
tentativas por cicloattemptsPerCycle: int — envios por donoo envio seguinte responde "tentativas esgotadas" — um conflito, não erro de permissão, e o contador reseta com o ciclo
exigir entradajoinRequired: true — participantes são uma associação, não todo mundo que jogaum envio de quem não é participante é recusado

Um torneio diário declara as quatro de ponta a ponta.

Erros

Uma sessão player chamando uma operação que este placar reserva para fn — um envio para um placar só de servidor, um fechamento antecipado de ciclo — é recusada como erro de permissão antes que algo seja escrito; a mesma chamada de uma função de nuvem passa. Um envio para um ciclo que já fechou é conflito em vez disso: o direito está lá, o ciclo não, e a repetição é um envio para o atual.

Limites

Todo limite com o que acontece na fronteira dele.

LimiteNa fronteiraNúmero
linhas por leituraa página é aparada, "há mais" continua verdadeiro, after: continuateto de página definido por projeto
janela em torno de um donoaparada simetricamenteteto de janela definido por projeto
registros num cicloo envio é recusado como conflito; sem descartemaxEntrants por placar; ilimitado quando não definido
tentativas por dono por cicloconflito "tentativas esgotadas", limpo pelo resetattemptsPerCycle por placar; ilimitado quando não definido
taxa de envio por donorecusa por limite de taxa carregando o momento em que uma repetição é permitidataxa definida por projeto
placares por projetouma Declaration nova é recusada no deploylimite definido por projeto
retenção de ciclos fechadoso ciclo deixa o armazenamento com um Event; leituras então respondem not-foundjanela de retenção definida por projeto

Fluxo do usuário

Uma semana do placar weekly-score: envios do servidor, uma leitura ao-meu-redor, o fechamento de segunda-feira e as recompensas dele.

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Files & UGC

Arquivos chegam em pedaços e são processados conforme chegam. Uploads, assets e as variantes derivadas deles, e conteúdo gerado por jogadores com um caminho de moderação.

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

Quando usar

  • Jogadores ou serviços sobem blobs — sessões em pedaços, retomáveis, com cotas legíveis por jogador.
  • O processamento precisa começar antes de o upload terminar — leia o arquivo como fluxo, pedaço a pedaço.
  • Conteúdo feito por jogadores precisa de um caminho de moderação — SubmitUgc, uma fila, um veredito, Hooks nas duas pontas.
  • Uma imagem mestre precisa servir muitas plataformas — derive variantes (redimensionar, transcodificar) e mantenha o original canônico.
  • Pule para payloads estruturados pequenos — um campo de registro de Data & Subscriptions os carrega sem sessão de upload.

Quem faz o quê

ActorNesta página
playersobe pedaços, lê arquivos em fluxo, envia UGC
moderatorrevisa a fila, aprova ou rejeita envios
backend-servicederiva variantes de asset; engancha upload e moderação; define cotas

De relance

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC, o caminho do jogador:

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

Os portões em torno dele são Hooks, o mesmo contrato de sempre:

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Os dois Hooks envolvem um passo de operação, nunca um Event: Before(Files.Upload) decide se o upload começa, After(Files.SubmitUgc) roda quando o envio já existe e o coloca na fila de moderação pelo Moderation.Enqueue do próprio módulo. Events — upload concluído, variante pronta, UGC enviado, veredito de moderação — vão para assinantes, e um assinante não veta nada.

O modelo

Um arquivo é bytes opacos mais metadados declarados — origem, tipo de conteúdo, tamanho — e não é um armazenamento de estado sobre o qual decisões são tomadas. O vínculo dele com o modelo de jogo corre no sentido inverso: um campo no seu tipo guarda a referência; o arquivo não sabe do jogo.

O que um tipo de arquivo declara.

DeclaraO que é
originconteúdo autoral, gerado pelo jogo, ou gerado por usuário — e os limites de tamanho e as políticas decorrem disso
admissible content typescomo lista declarada, nunca farejados dos bytes
size limitsverificados quando a sessão é aberta, pelo tamanho declarado, em vez de na última parte
part size and ordero upload é realizado por uma sessão: um tamanho de parte declarado, a ordem das partes, um ponto de retomada
derivativesopcionalmente, variantes nomeadas produzidas por um handler — e a prontidão de toda variante declarada é observável, então um cliente nunca adivinha se a miniatura já existe
storage prefixno campo de schema que carrega a referência: onde os bytes moram, e nada mais. Não é um diretório — sem renomear, sem mover, sem permissões num prefixo, sem operação recursiva. O arquivo continua endereçado pelo id ou pelo key dele, e o prefixo não participa disso

O que vale para todo arquivo.

SempreO que é
completioné idempotente por sessão: uma conclusão repetida devolve o mesmo arquivo em vez de um segundo
a checksumé obrigatório, e uma divergência é uma recusa, nunca uma aceitação silenciosa de bytes corrompidos
a published fileé imutável: uma edição é uma versão nova, e uma referência a uma versão continua apontando para o que apontava
authored contenté endereçado por chave mais versão, e é managed: não editado no console administrativo, porque o código é dono dele
an unfinished sessionmorre observavelmente: passado o prazo dela, ela é encerrada com um Event e as partes dela são liberadas
ownershipsegue o predicado de dono: arquivos gerados por usuário e por jogo têm dono como qualquer linha com dono, e os arquivos de um dono obedecem à política de exclusão de jogador — cascata, recusa ou anonimização, declarada em vez de assumida
read accesspode depender de um entitlement: um asset pago é controlado pelo entitlement de Catalog & Commerce em vez de por um segundo sistema de permissões

Cada ponto de extensão nomeia o tipo que entrega ao Hook — a intenção de upload antes do upload, o envio depois — então um Hook nunca recebe uma sacola sem tipo.

Erros

  • Sem entitlement responde not found, não forbidden — do contrário a lista de recusas revela quais complementos existem. Um arquivo retirado responde igual.
  • Uma sessão expirada é conflito: abra uma nova.
  • Uma parte fora da ordem ou do tamanho declarados, um tipo de conteúdo não declarado, e um tamanho além do limite são recusas de validação — e a de tamanho cai quando a sessão abre, não depois de os bytes terem viajado.
  • Uma divergência de checksum é recusa de validação que vale repetir: reenvie a parte.
  • A cota esgotada é conflito, repetível depois de liberar espaço.
  • Uma concessão de leitura expirada responde not authenticated — peça uma nova concessão em vez de tratar isso como problema de permissão.
  • Rejeição pela revisão é veredito, não recusa: o envio foi considerado e a resposta é negativa com razão declarada, então o que fazer em seguida depende da razão.
  • Taxa de upload excedida responde na categoria de limite de taxa, com prazo.

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • O tamanho de um arquivo por origem — o upload é recusado antes de qualquer parte ser aceita, não na última.
  • O tamanho da parte — a parte é recusada como falha de validação.
  • O tempo de vida da sessão — expired com um Event, e as partes são liberadas.
  • Cota de armazenamento por projeto e por jogador — uma sessão nova é recusada como conflito, e o que já está publicado nunca é apagado em silêncio para abrir espaço.
  • Versões de conteúdo autoral mantidas — a mais antiga é retirada, e uma versão referenciada por um ambiente em vigor nunca é.
  • A taxa de upload por Actor — limite de taxa com prazo.
  • Retenção de conteúdo gerado pelo jogo — passado o período, uma retirada com um Event.

Fluxo do usuário

Um nível construído por um jogador, do primeiro pedaço enviado ao veredito de aprovação.

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Analytics

Tudo o que precisa ser contado depois em vez de visto agora. Declare um evento de telemetria tipado, emita-o, e ele cai ao lado dos da própria plataforma — um nível concluído, um passo de funil, um evento econômico, duração de sessão, uma desistência no tutorial. Este módulo emite; ele não lê, não agrega, e não despacha nada para lugar algum por conta própria — a direção que um lote toma é do roteador, em Extensibility.

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

Quando usar

  • Algo precisa ser contado depois — um passo de funil, um nível concluído, um evento econômico, duração de sessão.
  • A comparação precisa sobreviver a builds do jogo — um tipo carrega uma versão de schema, então um funil de um ano atrás não é silenciosamente uma emenda de dois significados diferentes de um campo.
  • O volume é alto e uma linha perdida é aceitável se você disse que era — a telemetria é o único lugar do contrato onde perda declarada é legítima.
  • Pule quando alguém precisa reagir — um evento de telemetria não tem assinante algum; um fato que outros precisam ouvir é um Event de jogo.

Quem faz o quê

ActorPodeNão pode
any actordeclarar tipos no schema; emitir em nome próprio, um a um ou em lote; ler os tipos declaradospreencher o contexto; ler, consultar ou agregar o que foi emitido
backend-serviceo mesmo, e emitir em nome de um jogador por delegaçãoler telemetria — não há permissão de leitura, porque não há operação de leitura

De relance

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

A definição é schema, então o que chega carrega campos nomeados e tipados em vez de um blob JSON — e ela é declarada na forma portadora própria dela, não como um Event de jogo com uma flag, para que se possa saber qual dos dois é pela Declaration sem rodar nada.

O modelo

O que um tipo de evento de telemetria declara.

DeclaraO que é
nameo do próprio tipo
fieldstipados pelo sistema de tipos da plataforma; uma máscara de campo se aplica a eles como em toda parte
schema versionobrigatória, e não derivada da versão do SDK — um funil compara eventos coletados sob builds de jogo diferentes, e sem uma versão a comparação mistura o incomparável em silêncio
samplingque fração dos eventos deste tipo passa. Declarada no tipo, nunca escolhida pela implementação conforme a carga: uma fração que muda sozinha torna funis incomparáveis entre dias, e isso só é notado depois de decisões terem sido tomadas sobre eles
loss tolerancese este tipo tolera perda. A telemetria é o único lugar do contrato onde perda declarada é legítima
deletion behaviourcomo a exclusão de um jogador alcança este tipo — por apagar ou por anonimizar. O estúdio declara a política; o módulo a executa

O que vale para todo evento de telemetria.

SempreO que é
no addressing targetsem destinatário, sem grupo, sem assinatura. Querer endereçar um é o sinal de que um Event de jogo é o que se precisa
contexté da plataforma: ela acrescenta o Actor ou um marcador de anonimato, a sessão, o ambiente, a versão do build e o momento pelo relógio declarado. Quem chama não pode preenchê-lo: um chamador que substitua o Actor ou a versão do build recebe os valores derivados em vez dos passados
the sampling shareviaja com o evento: sem ela o número absoluto não pode ser reconstruído a partir do que chegou
lossé observável em agregado: a fração não entregue num período fica disponível a quem consome; observabilidade item a item não é prometida, porque em volumes de telemetria uma mensagem por perda viraria ela mesma um fluxo
emissionnão é idempotente: duas chamadas são dois fatos, e ela não aceita chave de idempotência — suprimir a segunda perderia dados. Não há como estabelecer o desfecho de uma emissão perdida, e nem se quer: a tolerância a perda é declarada de antemão, para todas as chamadas de uma vez
the eventssão o histórico: o módulo não mantém nenhum próprio

Erros

  • Um tipo não declarado, e um campo que não bate com o schema do tipo, são recusas de validação — nenhum dos dois é surpresa de runtime, porque um tipo alcança o console administrativo a partir da Declaration dele.
  • Um evento grande demais é recusa de validação; campos nunca são limitados em silêncio.
  • Taxa excedida responde na categoria de limite de taxa, carregando o tempo antes do qual repetir é inútil.
  • Um descarte por amostragem não é erro, e uma perda admissível também não. A chamada foi executada e o descarte é comportamento declarado; reportar qualquer um dos dois como falha tornaria comportamento declarado indistinguível de defeito.
  • Um lote é inteiro ou por elemento, e qual dos dois é declarado — nunca "o que tiver acontecido".

Limites

Cada teto nomeia o comportamento na fronteira; os números por trás deles chegam com o capítulo de limites da plataforma.

  • Taxa de eventos por Actor — recusa por limite de taxa com tempo. Exceder a taxa nunca descarta em silêncio: ou aquela recusa, ou um descarte declarado por amostragem, e não há terceiro desfecho.
  • Tamanho de um evento — recusa de validação, nunca campos silenciosamente limitados.
  • Tamanho do lote — rejeitado antes do envio em vez de aplicado parcialmente.
  • Tipos declarados por projeto — uma Declaration nova é rejeitada no momento do deploy, não em runtime.
  • Campos num tipo — o mesmo, no momento do deploy.
  • Período de retenção — quando ele expira o evento fica indisponível pelo período declarado.

Fluxo do usuário

O golpe fatal vira um evento de telemetria tipado, amostrado pela Declaration dele e contado depois. A ability e o Stat que o carregam são Entity Presets, não módulos.

Beyond the SDK

O plano do operador

O que deliberadamente não está no SDK. Ciclo de vida de projeto e ambiente, deploy e rollback, faturamento, administração de organização e de usuários, roteamento de cluster — isso pertence ao painel administrativo, ao CLI e à superfície MCP, não ao código de jogo. A única exceção deliberada é Schema as Code: schema é superfície de desenvolvedor, então está no SDK.

Um modelo, dois planos

O plano do SDKO plano do operador
Alcançado decódigo de jogoo painel de controle, o CLI, MCP
GuardaRooms · Entities · jogadores · comércio · Leaderboardsprojetos e ambientes · deploy e rollback · faturamento · administração de organização e de usuários · roteamento de cluster
Trabalha emDeclarations, Hooks, Events, Operationsas telas próprias do painel

Eles compartilham um modelo: a Declaration que você envia é a que o painel desenha. O que não cruza a linha é autoridade — código de jogo não pode implantar, faturar nem mover um inquilino.

Toda superfície do SDK — cada módulo, e os presets declarados em Entities — tem uma contraparte de operador no painel de controle, onde as mesmas Declarations são vistas e editadas do outro lado:

Superfície do SDKO que o operador vê
Schema as Code / Data & SubscriptionsEntities, migrações, o navegador de registros, visões salvas, importação/exportação
Entitymáquinas de estado, inspeção por instância
Entity Presetstabelas de drop, definições de ability, Stat e projétil, presets de World Object — ajustáveis ao vivo
Access & Rolesa grade de papéis: papéis × operações, filtros de linha, máscaras de coluna
Extensibilitycadeias de cenário com sobrescritas, ordem resolvida, rastros de invocação
Roomsa frota: Rooms, saúde do Tick, colocação, status de drenagem
Matchmakingfilas, tickets em voo, curvas de relaxamento
Catalog & Commercecatálogo, agenda de vitrines, recibos, reembolsos
Leaderboardsciclos, correção de registros (auditada), taxas de envio
Auth & Playersprovedores, sessões, banimentos, o cenário de autenticação
Files & UGCassets, filas de revisão de UGC, cotas
Analyticspainéis, encaminhadores, atraso de ingestão
Mapmapas e conjuntos de obstáculos, instâncias vivas
Visibility / Collision / Locomotion / Prediction & Lag Compajuste por Room: regras, pares de resposta, janelas, custo de pacote por Actor
Groups / Messagingnavegador de Groups, templates, filtros de moderação, agendas
Botsperfis, cotas de preenchimento, endpoints de cérebro
Inventory / Profileposses e transferências, visões e conjuntos de Entities com dono

A regra de desenho. Uma capacidade do SDK sem superfície no painel é invisível para live ops; uma superfície no painel sem capacidade no SDK é uma mentira. Os módulos entregam as duas metades juntas, e uma Declaration escrita em qualquer um dos lugares é o mesmo modelo nos dois.

A viagem de uma Declaration: o schema-author a escreve, playserv push a leva, o panel a desenha para o operator, e o reajuste cai em Rooms que já estão rodando.

Acesso de agentes

Tudo o que o painel mostra também é alcançável por ferramentas: a plataforma expõe uma superfície MCP (a mesma API que o painel usa), então agentes de IA e scripts operam projetos (bootstrap, schema, registros, jogadores, deploys) sob o mesmo modelo de acesso de qualquer outro Actor.

Beyond the SDK

Por baixo do capô: transporte e o hub

Uma referência de arquitetura, não uma superfície que você chama. Nada nesta página aparece no API contra o qual você escreve: não há socket para abrir, canal para escolher, envelope para preencher nem retentativa para agendar. O seu código de jogo nunca encontra a maquinaria desta página — esse é o ponto. Conceitos essenciais nomeia a pilha; o mecanismo mora só aqui. Ele está aqui para que um arquiteto possa conferir o que o SDK faz com uma conexão caída, um módulo desabilitado ou uma mensagem que precisa chegar exatamente uma vez.

A pilha de camadas

The runtime stack from your code down to the transports, with the line below which you never call anything

Cinco camadas, de cima para baixo: espaço do usuário, módulos, Primitives, o hub e os adaptadores de transporte abaixo dele. As duas de cima são espaço do usuário; tudo abaixo é assunto do próprio SDK.

  • Espaço do usuário é o seu código. Ele vê módulos, e o vocabulário para por aí.
  • Módulos são a camada aplicada: Rooms, Matchmaking, Inventory, Leaderboards. Eles formam um grafo, não uma árvore, que é o que o hub tem de resolver quando um deles é desligado (Inheritance & Composition é a forma em si).
  • Primitives são a primeira implementação que tudo referencia: dados, Events, RPC, Groups. Um módulo é uma montagem nomeada de Primitives mais as regras próprias dele.
  • O hub é o controlador: injeção de dependências, montagem de módulos, a sessão do usuário, a recuperação de estado, a qualidade de entrega das mensagens, e o roteamento de cada mensagem de entrada para o módulo montado para ela.
  • Transportes são adaptadores para um protocolo. Existem vários; o hub trata todos igual.

Transportes são adaptadores

Haverá mais de um transporte, e eles diferem de maneiras que de outro modo vazariam para dentro de todo módulo:

EixoFaixa
Formadirigido por mensagem ou por requisição
Canaisde canal único ou multicanal
Estadocom recuperação de estado de conexão, ou sem
ProtocoloTCP ou UDP

Hoje isso quer dizer WebSocket, o transporte UDP do PlayServ e HTTP puro. Cada um é um adaptador atrás dos próprios detalhes de implementação, e cada um expõe a mesma coisa para cima: uma sessão de transporte. O hub segura uma sessão, nunca um socket, então nada acima do adaptador raciocina sobre a interface de rede.

Uma fronteira declarada. Um canal de transporte e uma sessão de transporte por vez. Rodar um transporte de backend para Leaderboards enquanto um transporte de master-client carrega a sessão viva está fora de escopo, e a API não o promete — assinatura alguma só faz sentido com vários canais abertos. Se uma versão posterior abrirá isso é decidido junto com a superfície invariante por projeto; até lá, a forma de sessão única é o contrato.

O hub esconde o transporte por completo

Para baixo, o hub fala a interface de transporte. Para cima, ele oferece estado, Events e a sessão do usuário. Código de módulo e código de jogo são igualmente incapazes de dizer qual transporte está embaixo, ou como o hub agrupou uma chamada, ou o que ele fez para voltar a um estado consistente depois de um intervalo.

  • A sessão do usuário pertence ao hub, não a um módulo. Reconexão, retomada e recuperação de estado acontecem uma vez, no hub, para tudo o que estiver montado nele.
  • A qualidade de entrega pertence ao hub, não ao módulo de dados. Envelopes, retentativas e empacotamento são mecânica do hub.

Qualidade de entrega — exatamente três níveis

Um módulo declara apenas a garantia de entrega de que precisa:

NívelSignificado
at least oncereentregue até ser confirmada; o receptor tolera duplicatas
at most onceenviada uma vez, nunca repetida; a perda é aceitável
exactly oncededuplicada e confirmada; a cara, usada onde é exigida

Essa Declaration é toda a conversa sobre entrega. Como a garantia é cumprida não é assunto do módulo, e não é seu.

Injeção de dependências, montagem e módulos desabilitados

O hub instancia módulos e os monta — na raiz ou num espaço de nomes — resolvendo as dependências de cada módulo em relação aos Primitives e a outros módulos. Um build que não precisa de um módulo não o monta. A montagem tem espaço de nomes, e um segundo módulo reivindicando um ponto de montagem já ocupado é rejeitado no momento da montagem — a composição falha ali, nunca na primeira chamada para dentro dele.

Como os módulos formam um grafo, desligar um tem consequências rio abaixo, e o hub toma exatamente um de dois caminhos:

  1. Desabilitar a cadeia dependente. Todo módulo que precisa do ausente também é desligado, e as interfaces dele ficam ausentes em vez de falharem.
  2. Declarar funcionalidade degradada. Os dependentes continuam montados e anunciam o que já não conseguem fazer.

Não há terceiro caminho. Meio funcionamento silencioso — um módulo montado descartando quietamente as operações que já não consegue realizar — é o modo de falha que esta regra existe para evitar, e é por isso que uma dependência desabilitada é observável em vez de misteriosa.

Por que você não vai encontrar nada disto

Toda promessa das páginas de módulo é cumprida acima desta linha: uma mutação de Entity é a operação de rede, um Hook é uma função tipada, uma entrada é uma chamada. Os nomes da pilha podem chegar até você — Conceitos essenciais aponta para cá — mas a promessa é que você nunca chama nada disto, não que as palavras sejam secretas. As camadas abaixo existem para que essas promessas sobrevivam a uma troca de transporte, e uma página que você nunca precisa ler é a medida de isso estar funcionando.

O que você vai encontrar — o contexto de entrega em que os seus handlers rodam, quando um handle termina, e a implementação em memória contra a qual você testa — está uma página acima: Threads, tempo de vida e testes.

Start here

PlayServ SDK

Игровой бэкенд, который поставляется вместе с геймплеем. PlayServ — это backend-as-a-service для живых игр: студия эксплуатирует бэкенд своей игры — данные, игроков, Rooms, Matchmaking, коммерцию — не хостя его. SDK — это то, как ваш код на сервере и в движке работает с этой платформой.

Эта страница — короткий список того, что здесь действительно иначе. Всё ниже решается один раз на проект и конфигурируется, а не пишется; то, что вы вызываете, живёт на модульных страницах, и каждый раздел здесь заканчивается указанием на владельца.

One declaration you write, and the five things that happen with no further code from you

Симуляция — не ваш код

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Rooms, Collision, Locomotion, Prediction и синхронизация исполняются внутри платформы. Ваша игра — это Declarations (Entity, карты, способности, таблицы дропа, политика синхронизации), Hooks (ваши правила, вызываемые на названных шагах), Events (подписывайтесь, не опрашивайте) и Operations (то, что вы просите или командуете). Каждая модульная страница построена ровно вокруг этих четырёх.

То, чего вы не пишете, вполне конкретно — игровой цикл, сборка снимков, кодировщик Delta, разрешение коллизий, интегрирование движения, обработка переподключений, валидация попаданий с компенсацией лага. См. Getting Started, где строится ровно это.

Изменение объявленного состояния и есть сетевой вызов

Отправки нет. Вы объявляете, как поле синхронизируется — один атрибут рядом с полем, — и его изменение и есть сетевая операция: Delta против последнего подтверждённого состояния, аспект как единица политики, приоритет и частота отправки, удерживаемое окно, Hooks до и после изменения. Ничего ниже по течению вы не пишете.

Тот же ход применяется ко всему остальному объявляемому: Event, RPC, Group, оси Leaderboard. Declaration — это вход для типизированного API, для админ-панели, которая его отрисовывает, и для кодогенерации во все биндинги — поэтому версионируется Declaration, а не сгенерированный код. См. Data & Subscriptions и Schema as Code.

Интерфейсы следуют за Actor, а не за стороной

Клиентского SDK и серверного SDK не существует. Поставляется один SDK, а что вызову дозволено, решает Actor за ним — игрок, сервис, мозги бота, оператор.

Случай, ради которого это сделано, — машина игрока, которая создаёт Room'у и затем её ведёт: master-client, держащий интерфейсы room-owner и ничего больше. У сборки room-visitor нет ни kick, ни close — они не отключены, а отсутствуют.

Права складываются из атомарных разрешений, поэтому встроенных ярусов ролей нет, а роль ограничивает данные вплоть до строки и столбца. См. Авторитетность и Access & Roles — как записывается выдача права.

Один дизайн, суженный дважды — над одним стеком

Как это написано

SDK — это один дизайн с двумя сужающими выходами, и порядок — это правило: ничто не спускается на уровень ниже, пока уровень выше действительно не может это унести. Общие принципы одинаковы во всех биндингах. Форма языка берёт только то, что его парадигма не может выразить общим способом: у C# атрибуты, у Python декораторы — одно Declaration, записанное так, как каждый язык уже записывает эту мысль. Форма движка берёт только то, что движок переделывает над своим языком: в Unreal Declaration едет внутри собственного макроса рефлексии движка, а Unity C# — это тоже не серверный C#.

Как это работает

Ваш игровой код обращается к модулям и больше ни к чему. Модули собраны из четырёх Primitives — Events, RPC, Data & Subscriptions, Groups. Под ними лежит хаб, который вы никогда не зовёте: внедрение зависимостей, монтирование модулей, сессия, восстановление состояния, качество доставки сообщений. Ещё ниже — транспортные адаптеры, по одному на протокол, и какой из них несёт вызов, ваш код не решает и не замечает.

Обе половины целиком: Как устроен SDK, с завершением на Под капотом.

Модули складываются; ничто не наследуется

Базового модуля, от которого наследуются, нет, и иерархии, которую расширяют, тоже — модули образуют граф, потому что дерево допускает только ветви, а настоящие фичи их пересекают: Matchmaking резервирует места в Rooms, дроп размещает предметы через Map, чат живёт внутри Room'ы.

«Наследование» покрывает здесь четыре разных механизма, и их стоит различать: RPC Entity — часть Entity и не существуют больше нигде. Пресет — именованный набор аспектов, а не базовый класс. Переопределение шага платформы — атрибут на вашей замене. Модуль заимствует другой через декоратор, сужающий заимствованный интерфейс. См. Наследование и композиция.

Кто что видит — объявляется, а не фильтруется на клиенте

На сорока игроках снимок всей Room'ы нормален; на двухстах — нет, и лечится это не более толстой трубой. Правила интереса решают, кто получает какой срез, а пакеты на Actor и широковещание — два режима доставки одной объявленной модели: переход между ними это конфигурация, а не переписывание. Далёкие вещи деградируют через объявленные ярусы детализации, прежде чем исчезнуть.

Часть, которая является свойством безопасности, а не полосы: состояние, которое не должно утечь, не отправляется вовсе. Туман войны и поля, видимые только владельцу, отсутствуют в пакете, а не спрятаны на клиенте. Наблюдатели, админы и реплеи получают более широкий обзор, держа более широкое право, — это снова авторитетность, а не особый случай. См. Visibility.

Что переживает потерю хоста

Смерть хоста не заканчивает матч. Состояние Room'ы не копируется между хостами, пока идёт игра — единственный владелец и есть то, что держит упорядочивание вне консенсуса, — а переживаемым матч делает то, что состояние объявлено, а объявленное состояние хранится вне хоста. Оно снимается на объявленном интервале, и замена продолжает с последнего снимка.

Значит, у замены состояние целиком — но по состоянию на тот снимок. Полное, а не текущее. Стоит это игры с последнего снимка; чем не покрыто — всем, что вы держали только в актёрах движка. Деплой пользуется тем же механизмом, минус потеря: слив хоста — это путь отказоустойчивости, запущенный намеренно. См. What Survives Losing a Host и Rooms — про льготное окно, через которое игрок возвращается.

Любой шаг платформы может стать вашим

Каждый сценарий платформы — цепочка зарегистрированных функций, и вы заменяете звено или оборачиваете его. Вход, валидация входа, покупка, отправка, загрузка — каждое из них названный шаг, а ваша замена объявляется атрибутом, с выбором версии по условию и собственным шагом платформы как запасным.

Именно это конкретно означает «настраиваемая платформа», и именно это стоит вместо выдачи вам наших исходников: вы заменяете шаги, а не форкаете то, что их исполняет. См. Extensibility.

Одна поверхность, шесть языков

Один контракт, шесть проекций: C#, TypeScript, Python и Go на сервере; сгенерированные Unreal C++ и Unity C# в движке. Каждый пример кода на этом сайте показывает все шесть, и там, где у биндинга нет поверхности для шага, вкладка называет причину, а не притворяется: шаг исполняется вне движка, или право на этот вызов держит другой Actor.

Два следствия, которые стоит знать до выбора языка: RPC принимает объекты SDK по ссылке, а не расплющенными DTO, и асинхронные Primitives здесь основа, а не приделанное сбоку — Channel, Stream и групповая адресация, так что можно обратиться к целой Group'е и собрать ответы или потреблять файл частями, пока он ещё загружается.

Есть и детерминированный in-memory хост, который исполняет ваш игровой код без бэкенда за ним и с временем под вашим контролем, поэтому тест — это тест, а не гонка; см. Потоки, время жизни и тестирование.


То, чего в SDK намеренно нет — деплои, биллинг, администрирование организации и пользователей, — живёт на операторском плане. Боковая панель — карта всего остального; Getting Started — самая короткая дорога внутрь.

Start here

С чего начать

Играбельная арена (карта, танки, стрельба, дроп), объявленная от начала до конца. Ниже нет игрового цикла: симуляция исполняется внутри платформы, и это весь код, который есть.

Прежде чем начать. Проект со средой dev (создаётся в операторском плане, которому принадлежит этот жизненный цикл), CLI playserv, залогиненный в него, и пакет SDK для вашего биндинга — больше в игру не устанавливается ничего.

Путь пользователя

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

Каждый вызов, который делаете вы, — это один из примеров ниже; шаги между ними — платформа, действующая по тому, что сказало Declaration. Ability, Stat и DropTable на схеме — Entity Presets, то есть Declarations на Entity, а не отдельные модули.

1. Объявить мир

Entity — это ваша схема плюс её живые аспекты. Один атрибут на поведение, рядом с полем, которое он описывает:

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

Изменение поля [Sync] и есть сетевая операция. Собирать снимок не нужно, и вызывать отправку тоже.

Ни один тип в этом блоке не ваш, и каждым владеет одна страница:

В блокеОткуда приходит
Vector3, Statядро вашего биндинга
Body и формы телCollision
Motion и пять моделей движенияLocomotion
ObstacleSet, Drop, Flight, Ammo, EffectEntity Presets, которые их используют
EntryRequest, Verdict, StatEventpayload'ы Hooks, их передаёт модуль, на который вы вешаетесь
SeatMatchmaking
Scope, области синхронизацииVisibility
Tick, частоты TickRooms

Перечисления закрытые. Правило, которое не покрывает ни один член, пишется предикатом, а не новым членом: [Aspect("loadout", Visible = "owner == caller.player")] — так выражается видимость по полю, когда Scope.Owner не совсем то правило, которое вы имели в виду (Data & Subscriptions).

2. Объявить Room

Шаблон Room'ы говорит, что такое сессия, и называет Declarations, на которые опирается. Наследовать класс Room'ы не надо, и метод Tick заполнять не надо, потому что внутренности Room'ы принадлежат платформе:

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

Идентификаторы в кавычках — это ключи контента, а не свободный текст:

КлючЧто называет
rockпропс в наборе препятствий карты (Map)
ammo.shell, railgunпредметы каталога (Catalog & Commerce) — через это же выстрел списывает патроны, а подбор попадает в сумку
battle, arena, crate-loot, shellключи, которые регистрируют эти четыре Declaration

playserv push отклоняет Declaration, чей ключ не существует в целевой среде, поэтому опечатка в ключе падает на деплое, а не на первом касте. Где бы шаблон ни жил, его можно перенастраивать без передеплоя движка: отправленная модель — это то, что live-ops правит в панели.

3. Написать свои правила через Hooks

Hooks — облачные функции, которые платформа вызывает на названных шагах. Типизированный вход, типизированный выход: никаких мешков с контекстом, никаких логгеров в подписи:

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Ворота Before могут отклонить шаг; наблюдатель After исполняется после того, как шаг зафиксировался, и отклонить не может. Поэтому не доехавший стартовый набор стоит 20 снарядов, а не входа в игру. Остальное держит Extensibility.

Почти ничто в этом блоке не делает ту работу, на которую похоже:

СтрокаКто её на самом деле выполняет
срабатывает Stats.DepletedStat, достигший своего низа — 0 для Hp, потому что Declaration задало только Max
переход в смертьAtMin = "death" из раздела 1; Hook добавляет следствие, а не переход
урон[Effect(Damage = 35)] на снаряде, который платформа применяет при попадании
RollAtпорождается на Declaration [DropTable] — поэтому оно partial, и поэтому вкладка Go читается как drops.RollAtCrateLoot
подборпроезд по луту атомарно переносит предметы в Inventory игрока; этот перенос и есть Event changed, который отрисовывает HUD

Сгенерированные типы для движка

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

Запускайте её (или пусть запускает CI) после каждой отправки схемы: типы перегенерируются, а не правятся руками, и сгенерированный Tank — это отправленный Tank. playserv push читает движковый проект точно так же, как серверный: спецификаторы UHT и атрибуты C# и есть Declaration, поэтому нацелить CLI на проект UE или Unity — это весь шаг экспорта. На каком потоке приходит колбэк и когда заканчивается подписка — закреплено моделью рантайма (Потоки, время жизни и тестирование).

4. Подключить клиента

Клиентское API симметрично: те же модули, а что сборке дозволено вызывать, решает ключ, под которым она исполняется. Движковая сборка несёт ключ игрока — здесь projectKey, удостоверение на один проект и одну среду, выданное в панели и уехавшее внутрь сборки. Ролей он не называет: роли разрешаются на сервере на каждом запросе, а игрок за ними приезжает с SignIn. Движковые биндинги здесь первоклассные; серверные ведут ту же поверхность безголово (мозги бота, нагрузочный тест, инструмент эксплуатации):

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

Четыре вызова, и каждый отвечает своим:

ВызовЧем отвечает
Findпоставленным тикетом и местом, забронированным в Room'е. Бронь держится тот срок, который объявил template; упустить её стоит места, а не права играть
Joinразрешается, когда приехало текущее состояние Room'ы. Tank, которого template спавнит входящему участнику, — часть этого состояния, поэтому My<Tank>() отвечает на следующей строке, а всё после Join — живой трафик
Castвыстрел — это глагол способности, а не вторая поверхность: Declaration [Projectile] и есть способность с баллистикой сверху, поэтому Cast проверяет откат, боезапас и цель так же, как для рывка или лечения (Entity Presets)
Abilitiesгенерируется: codegen собирает объявленные способности и снаряды в один тип на каждый binding

Отказы приходят типизированным Problem платформы: код плюс причина для человека. C#, TypeScript, Python и Unreal его бросают; Go возвращает его значением ошибки — поэтому в той вкладке проверяется каждый вызов. Выделенный сервер Unreal или мастер-клиент исполняет тот же бинарь под ключом хоста, и его роли несут строки mc: Rooms → Hosting a room — эта поверхность от начала до конца, а Access & Roles — место, где объявляются ключи и роли за ней.

5. Отправить и играть

playserv push          # схема + Declarations + Hooks, один деплой
playserv open battle   # Room в dev-среде, живая в панели

playserv push сканирует проект, в котором исполняется — Hooks-атрибуты и Declarations, — и деплоит в среду, на которую вы нацелены (--env dev по умолчанию). Отдельный playserv schema push двигает только модель, а playserv schema diff — то, против чего отправка сдиффлена (Schema as Code).

Отправка приезжает целиком или никак, и отклоняется, а не сливается, если развёрнутая схема сдвинулась с момента вашего диффа. Изменение, которое поломало бы существующие данные, вообще не едет отправкой: оно становится миграцией, которую вы сначала читаете, а потом запускаете или отменяете (Schema as Code). Перенос модели из dev в prod — акт операторского плана, а не вызов SDK (операторский план).

playserv open battle создаёт одну Room'у из отправленного шаблона battle и открывает её в панели, где состояние Room'ы и её участники доступны для осмотра, пока вы против неё играете. Панель теперь показывает шаблон, карту, таблицу дропа и Hooks: ту же модель, которую вы написали кодом, и там её тоже можно править.

Числа, которых вы не выбирали

Capacity = 8, Hz30, Hz = 10 и Cooldown = 1.5f — это настройка этой игры, а не потолки. Свои лимиты платформы стоят выше, и каждый объявлен вместе с тем, что вызывающий наблюдает на границе:

На границеЧто получает вызывающий
присоединение сверх вместимости или в закрытую Room'уconflict — стоит повторить, когда освободится место
создание Room'ы сверх лимита на проект или на Actorотказ, и ничего уже созданного не распускается
слишком частое создание Rooms или входотказ по рейт-лимиту со временем ожидания
payload Event сверх лимита Room'ыотклоняется до отправки, а не обрезается
чтение сверх потолка строк для ролистолько строк, сколько разрешает потолок, плюс маркер, что список урезан

Сами числа зависят от среды и приедут вместе с лимитами платформы — поведение на границе их не ждёт (Rooms, Access & Roles, Auth & Players).

Куда дальше

  1. Учитесь на примерах, раздел сразу после этого: Leaderboard в Tanks, аптечки в Tanks или рецепт ежедневного турнира для мета-цикла — по одной настоящей фиче каждый, и каждый шаг ссылается на модульную страницу, которой принадлежит только что использованное.
  2. Как работает SDK, когда форма SDK начинает значить больше, чем следующая фича: Основные понятия — словарь, а четыре статьи отвечают на кто вызывает (Авторитетность), как выражается право (Access & Roles), из чего сделан SDK (Как устроен SDK) и как он исполняется (Потоки, время жизни и тестирование).
  3. Затем модули. У каждой модульной страницы одна и та же анатомия — тезис, Actors, когда применять, путь пользователя, примеры, модель, — поэтому второй читается быстрее первого, а пятый занимает минуты. Entity и Data & Subscriptions — те два, на которые опирается всё остальное.

Пути чтения по ролям

Какая бы ни была роль, сначала читайте Авторитетность — один SDK и право на Actor — общая предпосылка, — держа рядом Основные понятия.

ВыЧитать по порядку
Клиентский разработчик (Unity · Unreal client · TS)Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, затем по фичам: Inventory · Leaderboards · Messaging · Profile
Серверный разработчик (C# · TS · Python · Go)Schema as Code → строительные блоки → Entity → Extensibility → Access & Roles, затем модули, чьими Declarations вы владеете: Rooms · Matchmaking · Leaderboards · Catalog & Commerce
Разработчик выделенного сервера UnrealRooms (Hosting a room) → Bots → Locomotion · World Objects → Map → Что переживает потерю хоста
Examples

Leaderboard в Tanks

У Tanks, показательной арены из Getting Started, Leaderboard нет. Этот урок — проходить его можно в любой момент после Getting Started — добавляет недельный Leaderboard по фрагам в три шага: объявить его, отправлять счёт из Hook на смерть, прочитать его в клиенте. Каждый шаг ссылается на модульную страницу, которой принадлежит только что использованное, поэтому урок учит указанием, а не повторением.

Шаг 1 — объявить Leaderboard

Leaderboard — это Declaration: какое поле его ранжирует, как складываются повторные отправки, когда он сбрасывается и кто вправе отправлять. Aggregation.Increment прибавляет каждую отправку к текущей сумме, поэтому один фраг — одно очко. Submit.ServerOnly стоит по умолчанию и закрывает Leaderboard от клиентов — именно это делает шаг 2 единственной дорогой внутрь.

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

Отправьте его командой playserv push, и Leaderboard появится в панели — пустым, с уже запланированным недельным циклом: понедельник 00:00 UTC, потому что расписания в UTC. Оси, которых вы не задали, остаются со своими значениями по умолчанию. Полный список осей — на Leaderboards: владелец, ключ порядка, отображаемые поля, правила турнира.

Шаг 2 — отправлять из Hook на смерть

Tanks уже заканчивает жизнь через порог HP, объявленный на танке: на нуле срабатывает переход death, и платформа вызывает Hook после него. Hook — облачная функция, типизированная на входе и на выходе, поэтому отправка фрага внутри него это одна строка.

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By — это атакующий, которого несёт урон, поэтому никакая бухгалтерия не следит, кто в кого выстрелил. e.Id — собственный идентификатор Event, и передача его ключом идемпотентности это ровно то, что нужно Leaderboard с Increment: повторно доставленный Event смерти засчитывается один раз, а не два. Точка Hook, гарантии порядка и контракт на вето — это Extensibility; порог, который его запускает, — пресет из Entity Presets на танке; а строка счёта, которую он пишет, — обычные Data & Subscriptions, которые можно запросить.

Шаг 3 — прочитать Leaderboard в клиенте

Два чтения покрывают весь интерфейс: верх Leaderboard и окно вокруг локального игрока — пять строк выше, пять ниже и своя. Оба возвращают ранжированные записи с фрагами и отображаемым именем, готовые к привязке к списку. Подписка держит панель актуальной, пока идёт матч, и доставляет ранг только локального игрока.

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

Сброс в понедельник закрывает цикл, а не удаляет его, поэтому таблица прошлой недели остаётся читаемой по своей метке — тем же вызовом Top с аргументом cycle:. Hook на закрытие цикла для выдачи награды — естественный четвёртый шаг, он описан на Leaderboards.

Куда дальше

  • Leaderboards — оси, циклы, турниры и Hook перед отправкой, который срезает подозрительные результаты.
  • Extensibility — все точки Hook по порядку, с контрактом на вето.
  • Entity Presets — порог Stat, который запустил фраг на шаге 2.
  • Аптечки в Tanks — второй пример по Tanks: два Declaration и один Hook.
  • Getting Started — арена Tanks, которую этот урок расширяет.
  • Основные понятия — словарь, который предполагает каждая модульная страница.
Examples

Аптечки в Tanks

До
танк, получивший урон, остаётся повреждённым до смерти. Обратной дороги нет, поэтому каждый бой — это отсчёт, а у арены нет причины по ней перемещаться.
После
по арене появляются аптечки — на расстоянии друг от друга и подальше от тех, кто дерётся. Проехал по одной — вылечился. Больше в игре не меняется ничего — и код Room'ы тоже, потому что его нет.

Это второй урок по Tanks. Три шага и ни одного нового модуля: Declaration ящика, Declaration того, где ящики появляются, и один Hook на то, что происходит при подборе. Проходить после Getting Started, в любом порядке с уроком про Leaderboard.

Шаг 1 — объявить ящик

Ящик — это Entity с двумя применёнными пресетами и телом, которое сообщает о контакте, никого не останавливая. Именно Response.Pass на слое pickups делает его подбираемым, а не препятствием: контакт сообщается, движение проходит сквозь.

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

Двух вещей писать не пришлось: где ящик рисуется (клиент уже отрисовывает объявленные World Objects) и как его позиция доезжает до клиентов — [Sync] и есть сетевой вызов.

Владеют Entity Presets и Collision.

Шаг 2 — объявить, где появляются ящики

Размещение — тоже Declaration, и это тот шаг, который решает, будет ли механика ощущаться честной. Разнос не даёт ящикам сбиваться в кучу, дистанция от игроков — появляться посреди дуэли, а правило неповторения не даёт одному и тому же месту быть ответом каждый раз.

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

Допустимые позиции приходят из Map — таблица просит место на слое ground, и Map отвечает тем, до которого действительно можно доехать, так что ящик никогда не окажется внутри стены.

Владеют Entity Presets и Map.

Шаг 3 — лечить при подборе

Один Hook, и это единственный код в уроке. Он исполняется на платформе облачной функцией — поэтому и не появляется ни на одной из вкладок движков.

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

Три вещи этот Hook получает бесплатно, и каждая — причина, почему урок такой короткий:

  • Отказ типизирован. Танк на полном здоровье получает already at full health с причиной, которую клиент может показать, а ящик остаётся на месте — для того, кому он нужен.
  • Правка Stat авторитетна на сервере. Клиент не может её попросить, значит для подбора не надо писать исключение в анти-чите.
  • HUD обновляется, никто его об этом не просил. Adjust испускает Event changed, а клиент уже подписан на объявленные Stats танка. Сетевое сообщение писать не пришлось.

Владеют Extensibility и Entity Presets.

Что изменилось, а что нет

ДоПосле
повреждённый танкостаётся повреждённым до смертивосстанавливается, перемещаясь по арене
код Room'ынетпо-прежнему нет
новые смонтированные модули—ни одного: два Declaration и один Hook
исключения в анти-чите—ни одного: лечение авторитетно на сервере, как любая правка Stat

Куда дальше

  • Entity Presets — генератор дропа, World Objects и модель Stats, на которые опирался урок; все три — пресеты entity, а не модули.
  • Collision — слои, отклики и разница между сообщённым контактом и блокирующим.
  • Map — как выбирается допустимая позиция и что значит «достижимая».
  • Extensibility — все точки Hooks по порядку, с контрактом на вето.
  • Leaderboard в Tanks — второй пример по Tanks.
Examples

Ежедневный турнир

Что получится: ежедневный турнир с окном заявок, засеянными Rooms и выплатой призов — целиком из Declarations и Hooks на модулях, у которых уже есть свои страницы. Ни одного нового понятия здесь нет; это Leaderboards, Matchmaking, Rooms, Catalog & Commerce и Messaging, сложенные под один мета-цикл.

Шаг 1 — объявить Leaderboard с окном заявок и лимитом попыток

Турнир — это обычное Declaration Leaderboard'а плюс ограничения участия: окно заявок, лимит числа участников и попытки за цикл. В подсчёте не меняется ничего — ключ порядка, агрегация и сброс остаются ровно теми же, что на любом Leaderboard'е.

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

Отправьте его, и панель покажет пустую сетку с запланированным окном. joinRequired: true делает участников членством, а не всеми, кто играет, поэтому отправка от неучастника отклоняется. Остальной список осей и поведение каждого ограничения на границе — на Leaderboards.

Шаг 2 — окно открылось: пати заходит, Rooms засеиваются

Как только окно заявок открылось, игроки встают в очередь ровно как на любой матч: создать или присоединиться к пати, затем один вызов Find. Matchmaking размещает пати в турнирную сетку, а Rooms засеивают матч — тот же путь «размещение и место», которым идёт каждый матч, только в области очереди турнира.

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

Лимиты из шага 1 принадлежат Leaderboard'у, а не Matchmaking: очередь размещает пати, а участника сверх лимита или игрока за лимитом попыток встречает именно Leaderboard. Участник 65-й из 64 получает конфликт, и ничего при этом не вытесняется, а четвёртая отправка за цикл отвечает «попытки исчерпаны» — тоже конфликт, который снимается дневным сбросом, а не запросом права.

Шаг 3 — счёт отправляется Hook'ом на роспуск

Rooms не докладывают о победителе в Leaderboard сами; эта связь — Hook, с тем же контрактом Extensibility, что и везде: типизированный вход, типизированный выход, никакого мешка с контекстом. Hook Room'ы on dispose (Rooms) — последнее, что исполняется, держа в руках итоговое состояние матча, и отправка идёт оттуда.

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner и FinalScore — поля, которые шаблон Room'ы этой игры объявляет в своём состоянии сам; платформа ничего к снимку не добавляет (состояние шаблона объявляется в Rooms). Hook роспуска (Rooms.Disposed) передаёт итоговый снимок, поэтому матч не пересчитывается заново. Hook перед отправкой из Leaderboards всё равно исполняется первым — счёт в сетке подчиняется тому же контракту «исправь, срежь или отклони», что любая другая отправка.

Шаг 4 — цикл закрылся: награды выданы, игрок уведомлён

Дневной сброс из шага 1 закрывает цикл ровно так же, как любой сброс Leaderboard'а, и испускает Event CycleClosed, несущий метку закрывшегося цикла — именно эта метка заставляет Hook читать таблицу, которая только что закрылась, а не пустую, которая только что открылась.

Остальное делает один Hook: выдаёт приз через путь entitlement'а Catalog & Commerce и проталкивает результат через Messaging, так что отдельной задачи на выплату запускать не надо. Уведомление адресовано одному Actor, поэтому топ-8 — это цикл из восьми, каждый со своим аргументом rank в шаблон.

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

Числа из шага 1 — значения режима seed: LiveOps перенастраивает окно, лимит участников и число попыток в панели, а следующий деплой не перезаписывает правку молча. Сделать турнир недельным — одна правка: Reset.Daily() становится Reset.Weekly(DayOfWeek.Monday), а шаги 2–4 остаются как есть.

Куда дальше

How the SDK works

Основные понятия

Слова, которыми остальные страницы пользуются, не останавливаясь на объяснениях. Модульная страница исходит из того, что вы уже знаете, что такое Actor, аспект или Room, — здесь у каждого однострочное определение и ссылка на страницу, где механизм за ним живёт на самом деле. Прочитайте один раз перед модульным справочником — или возвращайтесь, когда слово окажется тяжелее, чем вы ожидали.

Три вещи слишком велики для статьи, и у каждой своя страница: Авторитетность — кто вызывает и что одно это решает; Как устроен SDK — из чего SDK сделан; Наследование и композиция — как модули строятся друг на друге. В этом порядке они читаются как одно рассуждение.

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

Четыре поверхности

Каждый модуль выставляет ровно четыре вещи, и каждая модульная страница построена вокруг них. Это и есть программная модель:

ПоверхностьЧто означает
Declarationsчто существует и как оно себя ведёт; пишется в коде или в админ-панели — модель одна и та же
Hooksваши правила, вызываемые платформой на названных шагах; разворачиваются как облачные функции
Eventsто, что платформа сообщает вам о случившемся, — подписывайтесь, не опрашивайте
Operationsто, что вы просите или командуете, из функции или с клиента

Actor

Кто делает вызов. Что вызову дозволено, решает Actor за ним, а не то, в какую сборку скомпилирован код, — рассуждение это Авторитетность, механизм (атомарные разрешения, составные роли, InterfaceGrant) — Access & Roles.

Эта документация берёт имена Actors из одного каталога — пресетов, которые поставляет платформа. Это набор пресетов, а не закрытый список (проект называет собственных Actors), но каждая строка actors в схеме, каждая строка «кто что делает» и каждая фишка на потоковой схеме в этих страницах пользуется ровно этими написаниями:

player · backend-service · operator · host · moderator · schema-author · architect · bot-brain · room-owner · room-visitor · entry-validator · spectator · match-organizer · warehouse-keeper · seller — и any, когда страница имеет в виду их всех.

Страница может дополнительно ввести сценическую роль для одной схемы — описательного участника вроде member или attacker, — если её собственная проза или таблица «кто что делает» вводит его первой.

Runtime surface

Где исполняется код. Эти четыре метки используются в каждой таблице Operations:

МеткаПоверхность
fnОблачная функция (C# · TypeScript · Python · Go). Авторитетная на сервере; главный дом ваших правил
clИгровой клиент (Unreal C++ / Unity C#). API симметричен; роли открывают меньше
mcПоверхность хоста Room'ы: master-client (клиент, владеющий Room'ой) или выделенный сервер Unreal под своим ключом хоста
admАдмин-панель / CLI / MCP — где SDK и операторский план делят одну модель

Именно эту ось постоянно путают с предыдущей. Где исполняется код и какой интерфейс Actor он держит — два разных вопроса: один и тот же код держит одни и те же права, где бы он ни лежал, и отличается только выдача.

Project & Environment

Project — это бэкенд одной игры, со схемой и её данными в изолированных Environments (dev, prod). Каждый вызов исполняется внутри пары Project + Environment.

Entity

Центральное существительное. Entity — это Declaration схемы плюс её живые аспекты: данные 0..*, состояния 0..*, RPC 0..*, Events 0..*, Hooks и история изменений. Танк, дверь, полоска Stat и квест — все они Entity, и различаются только тем, какие аспекты несут. Частые сочетания поставляются как пресеты (GameObject, Stat, Character, Interactable, Projectile). См. Entity.

Expected state

Запрос перехода может назвать состояние, которого он ожидает, — и тогда это либо оно, либо отказ. Запрос, который не называет никакого, оценивается против того состояния, которое машина держит в момент обработки платформой, — никогда против состояния на момент отправки.

Ответ описывает именно этот момент и ничего не обещает про дальнейшее: чужой переход, приземлившийся, пока ответ в пути, оставляет ответ верным и не отменяет его. Поэтому называйте ожидаемое состояние, когда исход зависит от того, что было раньше, а в остальных случаях не читайте ответ как снимок, переживающий вызов.

Room

Room — это игровая сессия, а не место, где исполняется ваш код. Платформе всё равно, что её хостит: выделенный сервер, master-client или сам бэкенд. Внутренности Room'ы наши; вы ведёте Room'у снаружи, из облачных функций и клиентов, через Declarations, Hooks, Events и Operations. См. Rooms.

Channel & Stream

Асинхронные Primitives, лежащие под всем. Channel — адресуемый топик pub/sub: Room, Group, Entity или ваш собственный. Stream — порционное течение в любую сторону: файлы потребляются по мере прихода порций, запросы могут стримить, а RPC может разойтись по Group'е и собрать ответы. См. Core.

Primitive

Один из четырёх кирпичей, из которых собран каждый модуль: Events (объявить, испустить, подписаться), RPC (вызов по сети), Data & Subscriptions (механика синхронизации) и Groups (один список, много слушателей). Room, чат и пул Matchmaking — один и тот же Primitive group под разными правилами. Если фичу нельзя выразить через эти четыре — это дефект дизайна, а не довод за пятый.

Hook contract

Один контракт везде: Hook before исполняется перед валидацией, получает типизированный payload, может его изменить или отклонить; Hook after исполняется, когда операция уже зафиксирована, получает запрос и результат и может добавить только побочные эффекты — провалить операцию он не может никогда. Hooks упорядочены; их может нести любой зарегистрированный шаг платформы. См. Extensibility.

Delta & Revision

Клиенты получают состояние в виде Deltas: только изменившиеся поля, закодированные против последнего состояния, которое получатель подтвердил. Каждая запись несёт Revision; условная запись отклоняется при расхождении. Одно понятие версионирования обслуживает синхронизацию, конкурентность и историю. См. Data & Subscriptions. (То, что Unreal называет репликацией, — какой клиент видит какое состояние и как часто — живёт здесь, а также в Visibility и Prediction & Lag Comp. Страница Что переживает потерю хоста — про другое: какая машина владеет Entity и какая будет владеть ею следующей.)

Tick

Rooms симулируются с фиксированным шагом. Каждое изменение состояния штампуется своим Tick; синхронизация, Prediction, компенсация лага и история считают в Ticks, а не в реальном времени. Данные несут своё настоящее время события — именно это делает перемотку и сверку точными. См. Prediction & Lag Comp.

How the SDK works

Авторитетность

Авторитетность — это абстракция, а не две сборки SDK. Клиентского SDK нет, серверного тоже нет. Есть один SDK, а что конкретному вызову дозволено, решает Actor, который его делает.

Master-client — это ни клиент, ни сервер

Машина игрока, которая создаёт Room'у и затем её ведёт, — master-client, — держит интерфейсы room-owner и ничего больше. Сервером она не является: она не может всего, что может сервер. Обычным клиентом — тоже.

Выделенный сервер — та же фигура с другой стороны: тот же клиент, только без отрисовки, и отдельного SDK ему не нужно. Разделяет их доверие, а не устройство, а доверие несёт выдача.

Интерфейсы следуют за Actor, а не за стороной

Модуль не выставляет «клиентский API» и «серверный API». Он выставляет то, что может room-owner, что может entry-validator, что может seller. Клиент и сервер — это сантехника; Actors — это предметная область. Внутри каждой модульной страницы поверхность сгруппирована так же: это для таких-то нужд, то — для таких-то.

Роль — это одновременно и право, и классификация

Здесь ровно одно измерение. Роль несёт то, что Actor дозволено, и она же — способ сказать, кому что адресовано. Второй оси тегов или ярлыков рядом нет: одно объявляется, одно проверяется, одно читается в админ-панели.

whoami — то, как об этом спрашивает код. Он сообщает Actor и интерфейсы, которые этот Actor открывает прямо сейчас, — а не статический список, запечённый в бинарник на сборке.

Где исполняется код и какой Actor он держит — разные вопросы

ВопросОтветы
Где исполняется этот код?облачная функция · игровой клиент · хост: master-client или выделенный сервер
Какой интерфейс Actor он держит?player · room-owner · entry-validator · seller · moderator · backend-service · …

Разложенные в сетку, эти две оси независимы, и достижима каждая клетка:

облачная функцияигровой клиентхост Room'ыадмин
player✓✓✓—
room-owner✓✓ — своя машина игрока, которая хостит✓—
backend-service✓—✓✓

Выделенная клетка — это код, исполняющийся на клиенте и делающий серверную работу. Он назван — room-owner — и это такая же выдача, как любая другая.

Любое сочетание законно. Облачная функция не привилегирована автоматически, а клиент не ограничен автоматически: права приходят из выдачи, а выдача объявляется. Права кода одни и те же, где бы он ни исполнялся, — отличается только то, что ему выдали.

Отзыв действует без перевыпуска учётных данных

Учётные данные называют личность. Списка ролей они не несут. Роли разрешаются на сервере, на каждый запрос, — а значит, клиент никогда не держит доказательство собственных разрешений, и после отзыва нечему устареть и продолжать предъявляться.

Два следствия, оба изложены там, где им место:

  • Отзыв роли вступает в силу без перевыпуска учётных данных — см. Выдачу роли.
  • Наблюдаемым он становится не позже объявленной границы устаревания кэша прав. Мгновенности мы не обещаем.

Где авторитетность объявляется, а не выводится

  • Тип Room'ы объявляет свой режим авторитетности, и значения по умолчанию нет: Tick ведёт либо наша симуляция, либо внешний авторитет — игровой сервер студии или клиент игрока в роли master-client. Это Кто ведёт Tick.
  • Насколько внешнему авторитету доверяют относительно исхода — отдельное Declaration на типе Room'ы: принять, проверить Hook'ом или не принимать. И снова без значения по умолчанию.
  • Какие учётные данные разрешаются в какую роль и что такое ключ хоста — это Access & Roles.
How the SDK works

Access & Roles

Роли складываются, а не зашиваются. Атомарные разрешения складываются в роли; роли ограничивают данные вплоть до строки и столбца и решают, какие интерфейсы модулей сборка вообще видит. Это заменяет деление ключей на клиентские и серверные: учётные данные называют личность, а их роли разрешаются на каждом запросе.

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

Когда применять

  • Нужны учётные данные уже, чем «клиент» или «сервер», — за ними на каждом запросе разрешаются составные роли.
  • Доступ к данным обязан останавливаться на строках и столбцах: ограничение по региону, маски PII, подрядчики только на чтение.
  • Сборка должна видеть только интерфейсы, которые открывает её роль, — kick/close у посетителя попросту нет.
  • Интерфейс обязан гасить кнопки честно — CanI вычисляет ту же политику, которую применит сервер.
  • Не нужно, если поставляемые пресеты (player, room-owner, seller, …) уже совпадают с вашими Actors — каждый модуль уважает их по умолчанию; полный каталог живёт на Основных понятиях.

Кто что делает

ActorНа этой странице
operatorобъявляет роли и политики, задаёт лимиты по строкам и столбцам, выдаёт роли, выпускает ключи
match-organizerтурнирный персонал из потока ниже: держит составной ключ, пропускает заявки, вернуть деньги не может
every actorпроверяет CanI перед действием; видит только свои открытые интерфейсы

Одним взглядом

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

Атом — это пара: ресурс и один из четырёх глаголов: read, write, execute, administer. Набор глаголов фиксирован, и случай, который в него не влезает, дробит ресурс, а не удлиняет список. Поэтому пропуск чужой заявки — это Rooms.Membership.Administer, а не собственный глагол ValidateEntry: действие над членством другого Actor — это администрирование, а вход самого себя — Rooms.Membership.Write на том же ресурсе.

Модель

ACL данных — это роль × операция × предикат строки × маска полей: одна модель, одинаковая независимо от того, написана она кодом, через API или в сетке ролей в панели.

Из чего сложен доступ.

ТерминЧто это
atomодна пара ресурс × глагол. Четыре глагола: read (получение, выборка, подписка), write (создание, изменение, удаление и действия за себя — войти, выйти), execute (вызов функции, применение способности) и administer (действия над другими: kick, close, принудительная смена состояния)
roleименованный набор атомов. Она может включать другую роль, а цикл во включении — ошибка конфигурации, а не то, что разрешается в рантайме
role presetпоставляется поверх атомов и продолжает работать без изменений для уже развёрнутых потребителей. Отправная точка, а не ограничение: проект объявляет собственные роли из тех же атомов
row predicateкакие строки — булев предикат над значениями сессии
field maskкакие поля, объявляется на роль и на операцию. Поле, которое роли читать нельзя, не возвращается вовсе, а не возвращается пустым

Во что разрешаются учётные данные.

Учётные данныеЧто открывают
player keyего несёт движковая сборка; игрок за ним приезжает со входом, а сборка видит строки cl в каждой таблице Operations
host keyего держит выделенный сервер или master-client, и его роли открывают строки mc
pushed codeисполняется под ролью проекта backend-service — именно это проверяет Authoritative = true у Leaderboard
a registered hookне даёт ничего сверх: ваша функция сохраняет ту роль, под которой развёрнута

Легенда меток (fn / cl / mc / adm) принадлежит Основным понятиям.

Что верно про любую проверку.

ВсегдаЧто это
a credentialне несёт списка ролей: они называют личность, а роли разрешаются на сервере на каждом запросе. Отзыв обесценивает закэшированное разрешение сразу, а не выжидает объявленную границу устаревания
delegationменяет область, но не возможности: действие от имени игрока меняет, какие строки видны и кому приписана запись, и не даёт ни одной операции, которой Actor не держал и так
the verbотвечает, какого рода эффект, а предикат отвечает, какие строки. Если два случая отличаются только тем, чья это строка, — это предикат; если отличается сам эффект — это другая операция и, возможно, другой глагол. Поэтому kick — это administer, а не write с широким предикатом
a hidden rowотвечает not found: отказ не должен становиться оракулом существования
an ownerвсегда видит себя, что бы ни говорил предикат
visibilityне безопасность — оптимизация Channel и разрешение это разные механизмы, и ни один не заменяет другой
a disabled moduleне имеет поверхности: включён модуль или нет — свойство сборки, поэтому кодогенерация для выключенного не порождает ничего, и недоступный вызов — ошибка компиляции, а не отказ в рантайме
a module's surfaceследует за Actor, а не за стороной (Авторитетность это обосновывает, а здесь её механизм): сборка room-visitor видит join, leave и чтение, сборка room-owner дополнительно видит kick, close и настройку, а whoami сообщает, какие интерфейсы открывает текущий Actor
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

Кто выдаёт роль. Выдача и отзыв роли у игрока, а также объявленная проектом роль по умолчанию для нового — это Operations на Auth & Players: тот модуль владеет личностями, а роль разрешается по личности в учётных данных. Эта страница владеет тем, что роль такое; та — передачей её из рук в руки.

Ошибки

  • То, что скрывает предикат, отвечает not found, а не forbidden — иначе сам отказ сообщает вызывающему, что вещь существует, а ровно от этого её и прятали.
  • Право, которого вызывающий не держит, отвечает forbidden там, где существование субъекта не секрет, и называет, чего не хватило, а не падает молча.
  • Поле вне маски отсутствует в ответе, а не присутствует пустым: пустое значение и замаскированное были бы неразличимы.
  • Роль, включающая саму себя — напрямую или по цепочке, — ошибка конфигурации: отклоняется как Declaration, а не разрешается в рантайме.
  • Делегирование никогда не расширяет возможности: вызов, который Actor не мог сделать от своего имени, отклоняется и когда сделан от имени игрока.

Ограничения

Каждый потолок называет своё поведение на границе; сами числа приедут с главой об ограничениях платформы.

  • Размер выборки под предикатом строки ограничен, и модель ACL объявляет эту границу, а не обнаруживает её. Чтение сверх потолка получает потолочное количество строк и маркер, говорящий, что список урезан, — но никогда молча укороченную страницу.
  • Граница устаревания разрешённого права объявлена, и отзыв её не выжидает — он обесценивает сразу.

Путь пользователя

Один ключ организатора турнира, от сложения роли до живой смены прав.

How the SDK works

Как устроен SDK

Два вопроса путают друг с другом, и у обоих короткие ответы. Как SDK написан — почему одна и та же мысль выглядит чуть иначе на Python и на Unreal C++. Как SDK работает — что стоит между вашим вызовом и проводом. Эта страница отвечает на оба один раз, чтобы этого не пришлось делать ни одной модульной странице.

Написан от общего к частному

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

SDK — это один дизайн с двумя сужающими выходами, и порядок здесь и есть правило: ничто не спускается уровнем ниже, пока уровень выше действительно не может это унести.

УровеньЧто здесь живёт
Общие принципыОдинаковы в каждом биндинге: поведение объявляется атрибутом рядом с тем, что он описывает; каждое Declaration, которое вы отправляете, — это Declaration, которое отрисует админ-панель; ваш код обращается к модулям и больше ни к чему.
Форма языкаТолько то, что парадигма языка не может выразить общим способом. У C# атрибуты, у Python декораторы — одно и то же Declaration, записанное так, как каждый язык уже записывает эту мысль. Язык, в котором такой конструкции нет, несёт то же самое Declaration иначе, и этот носитель называется там, где он применяется, а не подразумевается.
Форма движкаТолько то, что игровой движок переделывает поверх своего языка. Unreal C++ — не обычный C++: у него собственная объектная модель и собственная рефлексия времени сборки, поэтому Declaration там едет внутри собственного макроса рефлексии движка, на той позиции, где этот макрос уже принимает спецификаторы. Unity C# — тоже не серверный C#: рантайм постарше, базовая библиотека поменьше.

Прочитанное сверху вниз, это и есть причина, по которой шесть вкладок на каждом примере — не шесть разных API. Это один API, записанный шестью способами, а различия, которые вы видите, — это два нижних уровня, проступающие наружу.

Как это работает, от вашего кода вниз

Ваш игровой код видит модули. Это не упрощение для документации — это и есть весь контракт верхнего уровня.

  • Модули — то, к чему вы обращаетесь. Они образуют граф, а не дерево, и что это даёт — Inheritance & Composition.
  • Четыре Primitives — то, из чего собраны модули: Events, RPC, Data & Subscriptions, Groups. Room, чат и пул Matchmaking — один и тот же Primitive group под разными правилами. Если фичу нельзя выразить через эти четыре — это дефект дизайна, а не довод за пятый.
  • Хаб лежит ниже, и вы его никогда не вызываете: внедрение зависимостей, монтирование модулей, пользовательская сессия, восстановление состояния и качество доставки сообщений. Он назван один раз — на странице Под капотом.
  • Транспортные адаптеры стоят в самом низу, по одному на протокол, и хаб прячет их полностью. Их будет несколько — WebSocket, наш собственный UDP, HTTP, — и какой из них несёт вызов, ваш код не решает и не замечает.

Единственное, что модуль сообщает вам о доставке, — это её качество: хотя бы один раз или не более одного раза. Всё остальное о том, как байты туда попали, вам знать намеренно не положено — это та часть, которую мы оставляем за собой право ускорять.

Что вы из этого получаете

  • Один SDK, а не клиентский и серверный. Что вызову дозволено — это выдача Actor, а не флаг сборки. Это Авторитетность, и это самое весомое решение на этой странице.
  • Declaration — вход для всего. Отправьте его — и появится типизированный API, админ-панель его отрисует, а кодогенерация для каждого биндинга последует. См. Schema as Code.
  • Модули складываются, а не наследуются. Как именно и что здесь честно означает «наследование» — Наследование и композиция.
How the SDK works

Потоки, время жизни и тестирование

Цикл ваш. Мы доставляем ровно в одно место и никогда за вашей спиной. SDK не запускает ни одного потока, о котором вам пришлось бы знать, не выдаёт вам ни одного лока и вызывает ваш код из единого контекста, который вы выбрали на старте. Зовите нас с любого потока, какой вам нравится; мы зовём вас с одного.

Один контекст доставки, и цикл ваш

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

Экземпляр объявляет ровно один контекст доставки — единственное место, где исполняются все его обработчики. Он фиксируется при инициализации и не меняется всю жизнь экземпляра. Event, Delta данных, исход вызова — всё приезжает туда и больше никуда.

Форм у него две, и вы выбираете одну на старте:

  • Качаете вы. Рантайм сам по себе не делает ничего; вы вычерпываете ожидающие доставки из собственного цикла. Это форма, которую хочет движок: доставки приземляются на игровой поток, в выбранном вами кадре.
  • Владеем мы. Рантайм держит один выделенный поток исполнения. Это форма, которую хочет консольный хост или выделенный сервер.

Ни одна не запасная для другой, и третьего варианта с пулом потоков нет. Смысл обещания про один контекст в том и состоит, что вам никогда не придётся спрашивать, сколько потоков мы наделали.

Контекст никогда не аргумент. Ни один обработчик не принимает параметр «на каком я потоке», и спросить не у кого. Где исполняется ваш обработчик — свойство контракта, а не данные вызова.

Старт и остановка явные

Инициализация — это ваш вызов, и он отвечает исходом. Ничто не инициализируется лениво при первом использовании — это запрещено, а не просто не рекомендовано, и причина стоит предложения: ленивый старт переносит единственное место, где виден выключенный модуль, в тот произвольный вызов, который случился первым, — и там он читается как падение этого вызова.

Выключенный модуль называется на старте, и исход говорит, что из двух случилось: вся зависимая цепочка выключена — или вы работаете с меньшим, плюс список того, что недоступно. Молчаливого третьего случая нет.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Остановка ничего не отменяет. Это единственное место, где привычка из большинства SDK здесь активно неверна. Остановка явная, полная и идемпотентная — после её успеха ни один обработчик этого экземпляра больше не вызывается, — но про уже летящую работу она не говорит ничего. Операция, которую вы начали до остановки, остаётся обнаружимой теми средствами, которые эта операция назвала. Если нужно узнать, прошла ли покупка, остановка — не способ это выяснить.

У каждого handle один объявленный конец — и это никогда не сборщик мусора

Подписка, дескриптор отложенной работы, сессия — каждый из них handle, и у каждого ровно один конец, который называет контракт. Освобождение идемпотентно, поэтому освободить дважды — не ошибка.

Три следствия, в которых легко ошибиться:

  • Конец — это никогда не финализатор, не деструктор и не область видимости. Handle, который вы уронили на пол, остаётся открытым. Это баг в вашем коде, а не то, что мы тихо приберём, — потому что время жизни, зависящее от языка, было бы разным временем жизни в каждом биндинге.
  • Использование handle после его конца — объявленный отказ, с кодом. Не пустой результат, не неопределённое поведение и не общая ошибка «объект уничтожен», которая не несёт ничего, на чём можно действовать.
  • Оборванное соединение — не конец handle. Подписка переживает разрыв и продолжает получать после переподключения. Handles заканчиваются по причинам, которые называет контракт, и потеря сети в их число не входит.

Ни один handle не переживает выдавший его экземпляр: как только вы остановились, каждый выданный им handle находится в своём конце.

Звать из обработчика можно; ждать внутри него — нет

Зовите поверхность с любого своего потока. Каждый handle свободен от привязки к потоку, и это обещание, а не свойство сегодняшней сборки. Вы никогда не возьмёте наш лок, не подождёте на нашем барьере и не услышите, что что-то надо звать «под локом»: примитивов синхронизации в поверхности нет вообще.

Обработчики одного экземпляра сериализованы: два никогда не исполняются одновременно, а порядок внутри одного потока сохраняется. Поэтому обработчику не нужны собственные блокировки.

Сериализовано не значит дедуплицировано. Порядок — одно обещание; сколько раз доставлено сообщение — другое, объявленное на типе сообщения. При «хотя бы один раз» вы увидите одно и то же сообщение дважды, и ключ дедупликации, который всегда едет вместе с ним, — то, чем вы это отличите.

Начать операцию изнутри обработчика законно и не может привести к дедлоку. Но её исход никогда не приезжает внутрь того же обработчика — он возвращается отдельной доставкой на тот же контекст. Идти вглубь можно; разворачиваться внутри — нельзя.

Блокировать контекст доставки запрещено, и запрет этот не совет. Ждать сеть, ждать чужой лок, синхронно ждать собственный вызов — всё запрещено внутри обработчика. У запрета есть симптом: обработчик, который держит контекст сверх своего объявленного бюджета, порождает либо объявленную деградацию доставки, либо объявленный отказ. Чего он не порождает никогда — так это молчаливого замедления, которое вам предстоит обнаружить в сессии игрока.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

«Отмена» — это две разные вещи

В большинстве языков одно слово, здесь две операции, и разница наблюдаема:

Вы хотитеЧто это
перестать доставлять мнелокально. Удаётся всегда, в том числе с упавшим соединением. Освобождение подписки — это оно.
остановить работузапрос к платформе. Идемпотентен и ничего не обещает про то, случилась работа или нет.

Ошибаются во втором. Отмена принятой работы — это запрос, который может не успеть, ровно как таймаут, который тоже не означает «не применилось». После отмены любого из двух видов исход начатой неидемпотентной операции остаётся обнаружимым теми средствами, которые эта операция назвала.

И два сбоя различимы: отмена вызова, который до нас не доехал, — локальный сбой; отмена работы, которую мы приняли, даёт вам терминальное состояние из объявленного набора.

То, что вы получаете, — копия прошлого

Значение, доставленное в ваш обработчик, потом не меняется. Мы никогда не выдаём живую ссылку в наше собственное состояние, поэтому ничто из того, что вы держите, не мутирует между двумя строками вашего кода.

Поэтому держать доставленное значение дольше обработчика безопасно — но то, что вы удержали, это наблюдение момента, а не окно в настоящее. Deltas по дороге к вам могут сливаться, поэтому список сохранённых вами значений — не история того, что происходило.

И вы никогда не владеете нашими буферами. Никакого «взять и вернуть», никакого «собрать и отправить»: изменение объявленного состояния и есть сетевая операция.

Тестирование: реализация в памяти, а не мок

Есть полная in-memory реализация — та же поверхность, тот же набор объявленных исходов, без сети. Это отдельная вещь, от которой вы зависите, а не флаг на боевом рантайме.

  • Она не частичная. Операция, которую она не поддерживает, отклоняется объявленным кодом, а не отвечает выдуманным успехом. Тест, который на ней проходит, проходит не просто так.
  • Время ваше. Объявленные сроки — время жизни дескриптора работы, бронь, окно удержания — достигаются продвижением шага, а не сном.
  • Детерминизм объявлен и ограничен: порядок внутри потока, объявленный режим доставки, управляемое время. Детерминизм с плавающей точкой не обещан, поэтому полная симуляция здесь тоже не переигрывается.

Отличие от мока и есть суть. Мок проверяет, что вы вызвали то, что собирались вызвать. Это проверяет, что вызванное вами имеет смысл.

Что вы устанавливаете и нижняя граница версии

Ядро — один блок; опциональные модули — отдельные, у каждого объявленный состав и объявленный список обязательных зависимостей. Добавление блока никогда не меняет поверхность другого: модуль монтируется туда, куда говорит его Declaration, поэтому ничто не появляется и не исчезает в другом месте из-за того, что вы поставили рядом.

Если на опциональный блок ссылаются, а загрузиться он не может, это объявленный исход инициализации — то же место, где сообщается о выключенном модуле. Никогда не заглушка, которая молча ничего не делает.

Каждый биндинг объявляет минимальную версию рантайма, под которую собран. Ниже неё вы получаете отказ на инициализации, а не частичную работу: слишком старый рантайм иначе ломается на первой же возможности, которой ему не хватает, — а это где-то произвольно в вашем коде и обычно на машине игрока, а не на вашей. Поднятие этого минимума — ломающее изменение и проходит тот же процесс, что и любое другое.

Куда дальше

Building blocks

Core

Core — единственный объект, который вы создаёте, и всё остальное висит на нём. Один ключ на входе — и у вас есть контекст, личность, типизированные отказы, трассировка, батчи. Через него проходит вызов каждого модуля, и ни один модуль не поставляет свою версию.

Who addresses Core, the 4 things it provides, and the 1 module it builds on

Когда применять

  • Нужно знать, кто вы и где — личность, роли, открытые модули, project · env · регион, всё на одном объекте, который вы держите.
  • Облачная функция должна писать как игрок — запись приписывается тому игроку, а зафиксированы обе стороны: и функция, и игрок.
  • Повторы не должны применяться дважды — батчевые Operations несут ключ идемпотентности.
  • Отказ должен быть ветвимым и находимым поиском — каждый бросок это типизированный Problem со стабильным кодом.
  • Не нужно, если вам нужны сообщения, вызовы или состояние — это Primitives: Events, RPC, Data & Subscriptions.

Кто что делает

ActorНа этой странице
any actorчитает личность, контекст и роли через Whoami
backend-serviceдействует как игрок; батчит идемпотентные Operations
operatorчитает трассы упавших и повторённых вызовов

Одним взглядом

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

Личность живёт внутри самого Core. Ни один вызов никогда не принимает параметр session или ctx.

Модель

Что несёт каждая ошибка.

ПолеЧто это
codeмашиночитаемое имя отказа, и оно стабильно. Словарь — проекция уже существующих кодов платформы: новый код для отказа, который платформа уже называет, запрещён
categoryкласс, к которому относится отказ, — он и говорит, осмыслен ли повтор вообще
trace identifierидентификатор именно этого случая, присутствует всегда, в том числе у локальных ошибок, поэтому обращение в поддержку не требует сначала воспроизвести сбой
explanationчеловеческий текст, чтобы его читал человек, и он не стабилен: заголовки и объяснения меняются и локализуются в любой момент
per-field errorsсписок, который несёт отказ валидации, — поле, код, сообщение на каждое отклонённое поле

Потребитель ветвится по коду и категории, никогда по человеческому тексту — ни сравнением, ни подстрокой, ни разбором. Ошибка, из которой достижимо только сообщение, — дефект биндинга, а не форма, которую надо обойти.

Три происхождения, и это не одно и то же.

ПроисхождениеЧто случилось
platformона ответила отказом, неся код из каталога платформы
localSDK отказал до отправки, из собственного опубликованного словаря
unknownвызов был отправлен, а ответ не пришёл. Ни «платформа сказала нет», ни «мы не спрашивали»

Что верно про любой отказ.

ВсегдаЧто это
a refused operation applied nothingатомарность — обязанность платформы, а не ваша: никаких компенсирующих чтений на обычной ветке ошибки. Исключений ровно два, и оба говорят об этом там, где возникают, — таймаут, чей исход неизвестен, и батч с посэлементной семантикой
the delivery pathне меняет ошибку: тот же код, категория и происхождение доезжают до вас, бросает ли биндинг, возвращает ли результат значением или зовёт обратно по подписке. Путь, который несёт меньше другого, — дефект этого биндинга
a timeoutне исход: это третье происхождение выше, и что с ним делать — объявлено на операции, а не угадывается

Core несёт контекст, а не сообщения. Испускание фактов и подписка на них — Primitive Events; вызовы — запрос/ответ, односторонние, веером по Group'е — Primitive RPC; состояние, подписки и потоковые чтения — Primitive Data & Subscriptions, адресуемый через Entity. Аудитории, по которым все три расходятся веером, — четвёртый Primitive, Groups. Передачи с размером (загрузка, скачивание) выходят наружу в Files & UGC. Семантика монтирования — пространства имён, отклонение коллизий в момент монтирования — живёт на Под капотом.

Ошибки

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

Что видит вызывающий без роли. Строки fn и adm отказывают, а не деградируют: игрок или клиентская сессия, вызывающая «действовать как игрок», испускающая трассу или читающая её, получает Problem с кодом forbidden — учётные данные действительны, роли за ними такого права не несут, и повтор вызова с тем же ключом идемпотентности не меняет ничего. Урезанного варианта, который работает с меньшими правами и возвращает меньше, не существует.

Любой отказ — это типизированный Problem со стабильным кодом: те же коды, что документирует контракт провода, поэтому клиент может по ним ветвиться, а человек — их искать.

Ограничения

Лимит применяется на приёме. Вызов, который приняли, лимит уже прошёл и будет выполнен, сколько бы он ни ждал обработки; отказ, упавший на какой-то следующий вызов, ничего не делает с уже принятой работой. То есть заполнившаяся очередь — это очередь, которая идёт: повторить принятый вызов потому, что отказали соседнему, — это способ сделать работу дважды.

О лимите вы узнаёте, получив отказ, и читать больше нечего. SDK не выставляет наружу ни действующего значения, ни оставшегося запаса, ни предупреждения о приближении, и ничто про лимит никогда не показывается игроку. Отказ несёт всё, что есть:

  • категорию, а она и говорит, осмыслен ли повтор вообще
  • чей это был лимит
  • когда разрешён повтор и на каком окне

Ветвитесь по ним. Счётчика для опроса нет, и бюджета для показа нет.

Путь пользователя

Один упавший вызов, от броска до трассы, которую читает оператор.

Building blocks

Events

Event — это факт того, что нечто случилось, доставленный всем, кто должен об этом услышать. Применяйте для того, что случается один раз и что нельзя нагнать из текущего значения: выстрел, покупка, вход в Room'у.

Who addresses Events, the 5 things it provides, and the 1 module it builds on

Когда применять

  • Нечто случилось, и другие обязаны отреагировать: выстрел, запертая дверь, закончившийся матч.
  • Аудитория меняется — тот же emit доходит до отряда, до Room'ы или до одного Actor, и решает это target, который объявляет тип.
  • Хочется типизированных обработчиков с автодополнением — объявленный Event становится send. и on. на своей поверхности, у каждого свой контракт.
  • Факт должен читаться и через час — объявите тип удерживаемым и читайте его обратно по периоду.

Кто что делает

ActorНа этой странице
schema-authorобъявляет Events через [Event], отправляет схему
any actorиспускает через send., подписывается через on.

Одним взглядом

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

Event, объявленный внутри модуля или Group'ы, выходит наружу только там: squad.send.rallyCall существует, потому что rally_call объявлен для отрядов, и emit доходит до участников отряда. Event, который модуль испускает наружу, — часть его объявленного контракта; о необъявленных вызывающие не узнают никогда.

Модель

Что объявляет тип Event.

ОбъявляетЧто это
nameявное имя на проводе, объявленное, а не выведенное из символа
payloadсхема того, что несёт испускание
targetкуда уходят испускания этого типа: экземпляр Entity, Group, Room или глобальный контекст. Target-Group — это массовая доставка: один сигнал, много получателей. Меняющийся адресат выражается Group'ой, а не адресом, переданным на emit
clocksim_time или timestamp, никогда оба: sim_time для фактов внутри симуляции, которые участвуют в Prediction, компенсации лага и откате; timestamp для фактов вне неё, вроде покупки или входа
retentiontransient — доходит до тех, кто подписан в момент emit, и не хранится; или retained — хранится и читается обратно по типу и периоду, а не через поверхность запросов, которую несёт Data & Subscriptions. Объявляется, никогда не выводится из рода Event
termна типе retained: как долго он хранится и что происходит по истечении. «Вечно» в число значений не входит
deliveryне более одного раза, хотя бы один раз или ровно один раз — объявляется на типе, поэтому подписчику никогда не приходится спрашивать, каким режимом воспользовался emit; «ровно один раз» называет границы, внутри которых держится
contextконтекст, в котором объявлен тип, глобальный или локальный. Глобально объявленное имя видно в локальных контекстах; локально объявленное наверху не видно. То, что испускает модуль, — его контракт в любом случае: о необъявленном Event вызывающий не узнаёт

Что несёт испускание.

ПолеЧто это
typeобъявленный Event. Два испускания никогда не сливаются: два выстрела — это два Events, и второй не поглощает первый; именно это отделяет Event от поля [Sync], которое несёт Data & Subscriptions
payloadсоответствующий схеме типа
sourceиспускающий Actor плюс его экземпляр, когда испустил Entity. Event, испущенный клиентом, — заявление, а не факт: авторитетная сторона проверяет его прежде, чем от него что-то зависит
stampпо объявленным часам типа
dedup keyприсутствует при любом режиме доставки, потому что повторная доставка возможна во всех: дубликат транспорта, второе чтение удерживаемого Event
cause keyна Event, который платформа испускает из-за другого своего Event: идентификатор того, из чего он следует, — так цепочка восстанавливается по ключу, а не сравнением штампов

Что держит подписка.

ДержитЧто это
eventобъявленный тип, к которому она привязана
surfaceузел, на котором она взята, внутри объявленного target типа, — половина аудитории со стороны подписчика
handlerтипизированный по payload
positionоткуда она возобновляется, объявленно, чтобы переподключение молча не стартовало с «сейчас». Пропущенное в разрыве не переигрывается: transient-Event невосстановим, и прочитать обратно можно только retained

Что верно про любой Event, что бы ни объявлял тип.

ВсегдаЧто это
audienceотправитель её никогда не перечисляет: это объявленный target типа, суженный до подписанных, а затем ограниченный Access & Roles — публиковать и подписываться это разные права, ни одно не влечёт другого, и поток может быть закрыт предикатом даже там, где сам тип виден. Отправителю, который мог бы перечислить получателей, пришлось бы воспроизвести то, что Groups и Data & Subscriptions уже знают
phasesиспущен, затем доставлен — и больше ничего. У Event нет машины состояний: он случается один раз
orderingобещан внутри одного потока, а для Events поток — это один испускающий экземпляр: два Events от одного экземпляра приходят в порядке испускания. Между потоками порядок не обещан ни в какой форме — ни между двумя экземплярами, ни между Delta и Event об одном и том же изменении
crossing streamsкогда порядок между потоками нужен, механизм объявляется, а не предполагается: сведите сообщения в один поток или несите причинный штамп в payload
gap detectionтам, где режим допускает потерю, подписчик узнаёт о разрыве, а не молча его пропускает

Хранится факт после доставки или нет — это слот в его Declaration, а не решение, принятое на emit, поэтому один и тот же тип всегда хранится одинаково и ни одному вызывающему не приходится помнить, какой вызов каким был.

TransientRetained
Доходит дотех, кто подписан в этот моментдо них же и до подписчика, пришедшего позже
Послеисчезхранится объявленный срок
Читается обратнонетда, в пределах срока
За сроком—выборка отказывает, а не отвечает пустым
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

Ошибки

  • Публиковать и подписываться — разные права, ни одно не влечёт другого. Подписка без права отвечает forbidden, а не not-found: тип есть в объявленном контракте модуля, поэтому прятать нечего.
  • Подписка на тип, который модуль не объявлял, — ошибка контракта, выходящая наружу типизированным Problem, а не молчаливый no-op.
  • На emit три отказа валидации: необъявленный тип, payload, не прошедший схему, и target, который тип не допускает.
  • Выборка за сроком удерживаемого типа отказывает, а не отвечает пустой страницей.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Размер payload — сверх потолка публикация падает и Event не случается, а не обрезанный payload.
  • Частота публикаций на источник — отказ по рейт-лимиту с моментом, когда можно повторить.
  • Подписок на Actor — новая отклоняется, существующие сохраняются.
  • Объём удержания на тип — вытеснение по объявленной политике, по сроку, никогда наугад.

Путь пользователя

Один сбор, от Declaration до маркера, который каждый участник отряда видит на своём экране.

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

RPC

Типизированный вызов, чьё тело живёт где-то ещё. RPC — второй Primitive: объявите процедуру там, где ей место — на модуле или внутри Entity, — и каждый биндинг получит сгенерированный метод, который можно ждать. Глагол один — invoke: односторонность это режим, который называет Declaration, а не второй глагол, и никакого do нет.

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

Когда применять

  • Вызывающему нужен ответ — запрос/ответ с типизированным возвратом.
  • Вызывающий сообщает и идёт дальше — объявленный односторонний RPC, назад не едет ничего.
  • Работа переживает вызов — объявленный отложенный RPC отдаёт дескриптор работы вместо таймаута.
  • Один вопрос, много отвечающих — групповой вызов это N вызовов, и каждый ответ приезжает привязанным к отправившему его участнику.
  • Глагол принадлежит вещи — объявите его внутри Entity; RPC у Entity не живёт больше нигде (Entity показывает Declaration).
  • Не нужно, если никого не просят действовать: факт, на который другие просто реагируют, — это Event.

Кто что делает

ActorНа этой странице
schema-authorобъявляет RPC, их режимы и то, кому дозволено их звать
any actorвызывает отвечающий или односторонний вызов там, где Declaration это допускает
group memberотвечает на веерный вызов; назад едет по одному ответу на участника

Одним взглядом

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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);

Где исполняется тело — облачная функция, клиент, master-client или игровой сервер — это маршрутизация, объявляемая на метод; переопределения и middleware покрывает Extensibility. Упавший вызов бросает типизированный Problem (Core).

Модель

Что объявляет RPC.

ОбъявляетЧто это
nameиз словаря глаголов
inputаргументы, которые вызывающий обязан выбрать
outputровно один объявленный тип. Более короткий ответ — это собственный объявленный тип, а не тот же тип с тихо опущенными полями; иначе «не запрашивалось», «объект отсутствует» и «скрыто маской доступа» превращаются в одно неразличимое отсутствие
reply modewith a reply — значение объявленного выходного типа или типизированный отказ; или one-way — ответа нет, и вызывающий узнаёт только о локальном сбое отправки. Односторонний нельзя применять там, где вызывающему нужен исход: неизвестный исход стоит дороже известного отказа
execution modeimmediate — исход возвращается внутри вызова; или deferred — вызов возвращает дескриптор работы, а исход читается или приезжает подпиской. Объявляется, а не выбирается реализацией по нагрузке, потому что вызывающий строит своё поведение на форме ответа
streamingприходят ли вход и выход частями и обрабатываются ли по мере прихода, а не целиком
idempotencyодносторонний RPC тоже несёт ключ идемпотентности: отсутствие ответа не означает отсутствия повторной доставки
overridabilityобъявляется на самом методе. Нет Declaration — значит не переопределяем; переопределяемости по умолчанию не бывает
contextгде он объявлен. RPC, объявленный внутри Entity, — часть этой Entity и вне её не существует. Объявить его в игровом сервере и есть регистрация в роутере: второго способа добавить его нет

Что несёт вызов.

НесётЧто это
argumentsтолько то, что вызывающий обязан выбрать
implicit contextполучатель, вызывающий и окружающий контекст, привязанные до вашего первого написанного параметра, — у метода Entity никогда не спрашивают идентификатор этой Entity
referencesаргумент, который является объектом SDK, едет типизированным Ref — идентификатором или курсором, а не копией содержимого. Получатель разрешает его от своего имени, под теми же разрешениями и предикатами: ссылка это адрес, а не выданное разрешение
outcomeзначение объявленного выходного типа или типизированный Problem

Что держит дескриптор отложенного вызова.

ДержитЧто это
stateaccepted → running → completed или failed, последние два терминальны
lifetimeобъявлено; за ним исход недоступен, и спросить о нём — отказ, а не пустой ответ
cancelидемпотентна и честна: она просит, а терминальное состояние, которое вы наблюдаете, — то из completed или failed, до которого работа дошла

Что верно про любой RPC.

ВсегдаЧто это
one handlerровно один логический обработчик — именно это отделяет RPC от Event, у которого их может не быть ни одного. Поэтому обращение к Group'е — это N вызовов, а не один: Groups дают адреса, а ответы возвращаются потоком, каждый привязан к отправившему участнику
meaningпросьба выполнить действие, тогда как Event — утверждение факта. Односторонний RPC и Event снаружи похожи и не одно и то же: обработчик RPC обязан существовать, а у Event получателей может не быть вовсе, и это нормально
no state machineу Declaration её нет, и у немедленного вызова её нет — он либо вернул исход, либо нет, и тогда действуют правила таймаута. Наблюдаемые состояния есть только у отложенного
a streamне атомарен: потоковый выход не обещает ничего про целое — получатель обязан быть готов к обрыву и отличать «поток завершился» от «поток оборвали»
no predicate on a writeни одна запись не принимает предикат на вход: «сделай это всем, кто подходит под условие» — не операция. Массовое действие выражается перечислением: прочитать набор, отдать список пакетной операции с объявленной семантикой частичного отказа. На входе записи предикат вычисляется в момент, который никто не назвал, над набором, который никто не видел

Любой RPC доходит до обработчика через роутер, и какое из его направлений отвечает — объявляется на метод, а не является свойством места вызова; см. Extensibility.

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

Ошибки

  • Право на RPC, никогда не на Primitive. Общего «можно вызывать» не существует: каждое Declaration называет атом, который обязан держать вызывающий, и вызывающий без него получает типизированный отказ forbidden, с кодом и всем прочим, а не молчаливое отбрасывание.
  • Скрытый экземпляр читается как «not found». RPC у Entity, вызванный на экземпляре, который скрывает предикат строки вызывающего, отвечает ровно так же, как ответило бы чтение этого экземпляра, — поэтому отказ не сообщает вызывающему ничего о том, что существует.
  • Отсутствие обработчика — это «unavailable», а не «not found». RPC объявлен, значит существует; не хватает маршрута. Такой отказ повторяем — игровой сервер может вернуться, — тогда как «not found» велел бы вызывающему перестать пытаться.
  • Отказ Hook несёт собственный код и причину Hook, поэтому «отклонено правилом игры» никогда не приезжает в виде «сломался транспорт».
  • Таймаут — не исход. Для отложенного вызова вы читаете дескриптор; для немедленного повтор безопасным делает объявленный ключ идемпотентности — в том числе на одностороннем вызове, где отсутствие ответа не есть отсутствие повторной доставки.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Размер входа — вызов отклоняется до исполнения.
  • Размер выхода — отклоняется, а не обрезается, потому что подрезанный ответ неотличим от полного.
  • Частота вызовов на Actor — отказ по рейт-лимиту с указанием, когда повторить.
  • Одновременных отложенных вызовов на Actor — новый отклоняется, летящие доигрываются.
  • Время жизни дескриптора — за ним исход недоступен, и это отказ.
  • Глубина цепочки вызовов — при превышении объявленный отказ, а не исчерпанные ресурсы и не молчаливый обрыв.

Путь пользователя

Один отправленный счёт, один сообщённый пинг, один отряд, у которого спросили, готов ли он.

Building blocks

Data & Subscriptions

Вы меняете одно поле. Всё ниже по течению происходит без единой строки кода. Data — третий Primitive: механика под каждым синхронизируемым полем — Deltas против последнего подтверждённого состояния, аспект как единица политики, приоритет и частота отправки, возобновляемые подписки, удерживаемое окно и Hooks до и после изменения.

Вы обращаетесь к Entity, а не к таблицам — поверхность чтения и изменения см. в Entity (найти, отфильтровать, отсортировать, разбить на страницы, подписаться на выборку); эта страница — механика под ней. Потребительского пути к таблице нет, и второго способа писать нет: изменение — это Operation у Entity, а Delta — то, что из него следует.

Who addresses Data, the 5 things it provides, and the 1 module it builds on

Когда применять

  • Нужно состояние, реплицированное на клиенты без кода снимков, — изменение поля и есть вся синхронизация.
  • Поля различаются срочностью или аудиторией — приоритет и потолок частоты отправки на аспект, плюс предикат видимости для тумана войны.
  • Переподключающийся клиент не должен молча разойтись — разрыв обнаруживается и называется, а разрыв за удерживаемым окном получает в ответ полное состояние.
  • Нужно недавнее прошлое — удерживаемое окно Deltas, индексированное по sim_time, — это то, что читают Prediction и компенсация лага.
  • Правилу валидации место в одном месте — Hook до изменения срезает или накладывает вето, прежде чем изменение приземлится.
  • Крутилки не нужны, если всё, что вам надо, — читать или запрашивать: поверхность Entity едет на этой механике, не прикасаясь к ней.

Кто что делает

ActorНа этой странице
schema-authorобъявляет аспекты, их политику синхронизации и предикат видимости
any actorподписывается на target; возобновляется с позиции; запрашивает полное состояние
backend-serviceHooks до и после изменения
operatorчитает стоимость пакета на Actor; видит, когда доставка деградирует или пакет режется

Одним взглядом

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

Модель

Что несёт Delta.

ПолеЧто это
changed fieldsтолько они, никогда весь объект
pairпара экземпляр × аспект, которой она принадлежит
numberпорядковый номер внутри этой пары — именно он делает разрыв обнаружимым

Что объявляет аспект.

Всё — атрибутом, на аспекте или на отдельном поле, никогда вызовом в рантайме. Аспект задаёт значение по умолчанию, а поле может его перебить; единицей политики остаётся аспект, потому что иначе не из чего собирать пресеты.

ОбъявляетЗначения и то, чем это не является
priorityупорядочивает, что отправляется первым, когда канала не хватает. Не обещание задержки: он относителен и упорядочивает отправку между полями, а не гарантирует срок доставки
max update rateверхняя граница на отправку. Не обещание получать с такой частотой — получение зависит от канала
delta onlyне отправлять то, что не изменилось
delivery modeshared packet — одно и то же всем, дёшево по CPU; или per-actor packet — каждому своё по его зоне видимости, дорого по CPU и необходимо на больших населениях
visibility ruleпредикат, решающий, кто вообще получает, — эту половину целиком проецирует Visibility
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

Что держит подписка.

ДержитЧто это
targetэкземпляр, выборка или аспект; она получает Deltas этого target. Target — не поток: один target может покрывать много пар, а порядок обещан внутри пары, а не поперёк target
positionоткуда она возобновляется: её предъявляет потребитель. Если разрыв больше удерживаемого окна, вместо потока Deltas приезжает полное состояние, поэтому долгое отключение никогда не оставляет клиента молча неправым
stateactive → gap detected → resynchronised | closed, и closed терминально

Что верно про любой поток.

ВсегдаЧто это
mergingDeltas его допускают: 100 → 90 → 80 между отправками могут приехать как 100 → 80, потому что итоговое состояние всё равно верно. Именно это отделяет Delta от Event, где потеря одного теряет информацию навсегда
gap detectionмолча потерять Delta запрещено; потребитель считает порядковый номер в паре
orderingдержится внутри одной пары экземпляр × аспект; между парами не обещан ни в какой форме
traversalидёт только по объявленному: тем, что может быть фильтром, сортировкой или включением, является объявленное поле и объявленная ссылка. Поверхность выборки Entity — проекция этой модели, а собственного обхода этот Primitive потребителю не даёт: второго языка запросов нет
historyстроится из Deltas: мгновенное окно Entity — это удерживаемое окно Deltas, индексированное по sim_time. Его глубина — лимит этого Primitive, и воспроизводимости по полям с плавающей точкой он не обещает
the packet budgetдеградирует как объявлено: когда бюджет на Actor кончается, платформа откатывается к общему пакету как объявлено, а не начинает произвольно терять получателей

Что может Hook и когда.

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

HookЧто ему дозволено
до измененияизменить его или наложить вето. Заветованное изменение не порождает никакой Delta — подписчики не видят ничего, а не значение и следом поправку
после изменениядобавить побочные эффекты, и провалить изменение он не может никогда

Удаление вешается на Entity, где это удаление и живёт; этот Primitive вешает изменение.

Ошибки

  • Необъявленный target подписки — отказ валидации.
  • Отсутствие права на подписку отвечает forbidden или not found в зависимости от того, является ли само существование target секретом: отказ не должен становиться оракулом.
  • Позиция возобновления, которая не разбирается, — плохой запрос, а не молчаливый рестарт с «сейчас».
  • Подписка, закрытая со стороны платформы, — конфликт, и он наблюдаем: closed у машины терминально, и клиенту не приходится его выводить.
  • Исчерпанное число подписок — конфликт: разрешение держат, места нет.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Окно удержания Deltas — возобновление старше окна отдаёт полное состояние, а не отказ.
  • Подписок на Actor — новая отклоняется, существующие продолжаются.
  • Размер Delta — Delta разрезается, а не обрезается, и разрез наблюдаем.
  • Частота отправки — верхняя граница, а не гарантия.
  • Стоимость пакета на Actor — при исчерпании объявленная деградация к общему пакету.

Путь пользователя

Одно изменение позиции, от присваивания до исправленного движения на каждом экране.

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Groups

Один список, один массовый слушатель. Group — четвёртый Primitive: именованный набор Actors, который получает как один. Вы обращаетесь к Group'е, и слышит каждый участник — Room, чат, пул Matchmaking и список рассылки это один и тот же Primitive под разными правилами: разная логика входа и выхода, разное время жизни, один и тот же список внизу.

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

Когда применять

  • Нужны пати, отряды или гильдии — именованные наборы игроков с объявленной вместимостью и, где тип её объявляет, со временем жизни.
  • Членство должно следовать объявленному правилу, которое вычисляет платформа, — новые ветераны попадают внутрь без крона и без вашего собственного вызова «пересчитать».
  • Хочется обратиться сразу ко многим игрокам: объявленный Event расходится веером через send.*, объявленный RPC достаёт каждого участника, и каждый ответ приходит именованным.
  • Нужна одна модель членства, переиспользуемая как аудитория — область Visibility, разговор Messaging, пати Matchmaking.
  • Не заводите свою, если набор — это участники одной сессии: Rooms и есть этот Primitive с правилами Room'ы, и он их уже адресует.

Кто что делает

ActorНа этой странице
playerсоздаёт Groups из объявленных типов, входит и выходит, добавляет и удаляет участников, шлёт Events, вызывает веерные RPC; держа право администрирования членства в Group'е, удаляет участников и закрывает её
room-ownerправила мест в Room'е едут на этом Primitive (настраивается в Rooms)
backend-serviceобъявляет типы Groups и их правила; вешает Hooks на вход и выход

Одним взглядом

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

Чат Room'ы — это тот же Primitive с семантикой сообщений сверху: Room объявляет собственный тип Group'ы, монтирует его в своё пространство имён и позволяет собственному членству Room'ы решать, кто внутри, — поэтому список чата и список Room'ы разойтись не могут.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

В Primitive нет ничего про чат. Аудитория и поверхность send.* приходят отсюда; автор, тред и история — из Messaging, а кто вправе администрировать членство — самостоятельное право (Access & Roles), которое держит Room.

Модель

Что объявляет тип Group'ы.

ОбъявляетЧто это
nameсобственное имя типа
membership modeexplicit — участник добавляется и удаляется действием; или dynamic — членство выводится из правила, и участник тот, кто удовлетворяет предикату. Group — одно из двух, никогда оба
ruleдля динамической Group'ы: предикат, на том же языке предикатов, что предикаты доступа и стражи переходов. Пересчитывает его платформа; никто не опрашивает
capacityи поведение при её достижении
entry ruleпредикат, который может отклонить вход, отдельно от Hook, который тоже может его отклонить
lifecycle behaviourна первом входе — created on first entry или created in advance; и на последнем выходе — closed on last exit или kept while empty. Объявляется, никогда не выводится из наблюдения
lifetimeнеобязательное: по истечении Group закрывается с Event

Что верно про любую Group'у.

ВсегдаЧто это
memberэто Actor, никогда не Entity: набор Entities — это выборка над Data & Subscriptions. Group — один массовый слушатель
statescreated → active → closed, и closed терминально. У экземпляра Group'ы есть машина; тип её не объявляет
event targetиспустите на ней — и получат её участники; именно это делает массовую доставку одним сигналом, а не циклом
a group callэто N вызовов, а не один: Group даёт адресацию, и каждый исход приезжает привязанным к тому участнику, от которого пришёл. RPC требует ровно одного логического обработчика, поэтому широковещание, ждущее многих ответов, — это N вызовов, а не один
partial outcomeникогда не читается как полный: участник, который упал, вышел по таймауту или отказал, — это собственный ответ со своим Problem рядом с ответившими; частичный успех никогда не возвращается как полный
recipientsотправитель их никогда не перечисляет: решает членство, поэтому отправителю не нужно знать состав аудитории
intra-group rolesне существуют: «владелец Group'ы» — это Actor, держащий право (Access & Roles), а не звание, хранимое в списке участников
first entry and last exitотличимы от входов и выходов между ними — на этом и висят объявленное поведение жизненного цикла и инициализация раунда
recomputationнесёт Delta: Event состава Group'ы, объявленной правилом, говорит, кто вошёл и кто выпал, а не весь список. Весь список — это чтение, поэтому подписчик, которому нужно только изменение, за состав не платит
join and leaveидемпотентны: переподключающийся клиент повторяет свой вход, получает то же членство и никакой ошибки — клиентскому коду никогда не приходится отличать «я уже внутри» от «мне нельзя внутрь»
the interfaceпринадлежит конкретной Group'е, а не только типу: вы обращаетесь к этому отряду
the primitiveостаётся пустым: правила входа, инициализация раунда на первом участнике, перехват Events — это модули, построенные на нём. Room — Group с правилами мест, разговор Messaging — Group с правилами доставки, пул Matchmaking — Group, которую вычерпывает матчер, список рассылки — Group вообще без правил

Ошибки

  • Правило, сказавшее нет, и Hook, сказавший нет, — разные ответы. Ложное правило входа читается как «вход невозможен»; отказ Hook несёт собственную причину и код. Hook на вход, до которого не достучаться, вход отклоняет — проверка падает закрытой, а не машет Actor'у проходить.
  • Динамическое членство отказывает в ручной правке. Add или Remove на Group'е, объявленной правилом, — отказ валидации: этот список двигает только предикат, а платформа пересчитывает его, когда меняются данные под ним.
  • Четыре права, ни одно не влечёт другого — войти, администрировать членство, публиковать в Group'у, читать состав (Access & Roles). Actor, у которого одного не хватает, получает отказ, а не молчаливый no-op; Group, скрытая от него предикатом видимости, отвечает вместо этого not found, а собственное членство участник видит всегда, даже когда состав от него закрыт.

Ограничения

  • Полнота — это конфликт, а не вопрос права. На вместимости + 1 вход отклоняется как конфликт — Actor был допустим, места не было, — и тот же вызов удаётся, как только место освободится. Сама вместимость на типе (Capacity = 4 выше); сколько Groups может держать проект и один Actor, задаётся вместе с главой об ограничениях платформы.
  • Слишком большой групповой вызов отклоняется целиком, до того как что-либо отправлено, — веер никогда не доставляется наполовину, поэтому этот случай не приходится обнаруживать ни одному вызывающему. Потолок размера приедет с главой об ограничениях платформы.

Путь пользователя

Собирается пати, один сбор доходит до каждого участника, один веерный RPC приносит по ответу на участника, и отряд встаёт в очередь как единое целое.

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Extensibility

Каждый сценарий платформы — цепочка зарегистрированных функций. Замените звено или оберните его. Именно это конкретно означает «настраиваемая платформа», и именно это стоит вместо открытого кода: вы заменяете собственные шаги платформы своими, поэтому наши исходники вам не нужны.

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Шаг платформы обязан исполнять вашу логику — объявите замену для названного звена через [Override(…)].
  • Нужны проверки или побочные эффекты вокруг шага — упорядоченный Before/After middleware, который может наложить вето или уведомить.
  • Код должен исполняться по расписанию, по Event или по вебхуку — триггеры отдают вам разобранный типизированный контекст.
  • Надо знать, что реально исполнится, до деплоя — прогоните цепочку вхолостую и прочитайте разрешённый порядок.
  • Не нужно, если правило касается записей одной Entity: Hook из Data & Subscriptions — форма полегче.

Кто что делает

ActorНа этой странице
backend-serviceпереопределяет звенья, оборачивает шаги middleware, пишет обработчики триггеров
operatorосматривает цепочки, задаёт порядок, читает секреты, прогоняет разрешение вхолостую

Одним взглядом

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.

Модель

К чему платформа даёт вам прицепиться.

ТерминЧто это
registered functionодин переопределяемый шаг платформы — «создать профиль», «разрешить цену»
scenarioупорядоченная цепочка, которую исполняет поток платформы: вход, join, покупка
overridabilityможно ли звено заменить, только обернуть или оно фиксировано
middlewareупорядоченный до/после обработчик вокруг звена
triggerто, что запускает ваш код: Event, расписание, вебхук
secretзначение, которое ваш обработчик вправе прочитать
invocationодин прогон, со своей трассой
A scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

Что объявляет Hook.

ОбъявляетЧто это
positionназванный шаг, к которому он цепляется
kindgatekeeper — проверка допуска или валидация, и он падает закрытым, поэтому шаг не исполняется, когда ломается сам Hook; или observer — лог, уведомление, счётчик, и он падает открытым: шаг исполняется, а о сбое всё равно сообщают, а не проглатывают его. Значения по умолчанию нет
momentbefore — перед валидацией, получает типизированный payload, может изменить или отклонить; или after — когда шаг уже зафиксирован, получает запрос и результат, только побочные эффекты, и провалить операцию или изменить ответ он не может никогда
effectчто Hook делает, а не только где он стоит. Именно это превращает «кто из них исполняется первым» из драки за числа в высказывание о работе, и именно поэтому порядок переживает то, что кто-то добавил Hook рядом с вашим
version and conditionобработчик, применимый к одной среде или аудитории, — это объявленная версия этого Hook, а не ветка внутри его тела, и именно её переключает тумблер в панели

Три способа прицепить код.

ФормаДля чего
атрибут [Before(Step)] / [After(Step)]одно правило на один названный шаг — большинство Hooks
атрибут [Override(Link)]замена реализации звена целиком
Extend.Scenario("…").Before("link", fn, order: n)обёртка звена внутри цепочки, когда важен порядок относительно другого middleware
ВсегдаЧто это
all threeразворачиваются через playserv push
the two attribute shapesэто то, что отрисовывает панель, потому что Declaration несёт имя шага или звена в отправленную модель
the middleware formнесёт вместо этого порядок — то, что нужно цепочке
assigning at startup(Scenario.OnX = fn) остаётся доступным для обработчика, которому не надо появляться в админском дереве
replacing one linkоставляет соседние звенья нетронутыми, и ни одно из них не знает, какая реализация ответила — собственный шаг платформы или ваш

Куда уходит вызов и что верно про любой маршрут.

ВсегдаЧто это
four directionsоблачная функция · внешний бэкенд потребителя · игровой сервер · другое объявленное
the routerуправляется сообщениями и сигналами; request-response — один адаптер поверх него, а не его природа
matchingидёт по объявленному имени операции или сигнала и ни по чему больше: не по форме payload, не по вызывающему, не по нагрузке
a name registered twiceдефект Declaration, отклоняемый при объявлении набора, а не разрешаемый в момент вызова
an unregistered nameотвечает not found, а не молча отбрасывается
the directionне часть контракта операции: перенос обработчика между направлениями не ломающее изменение
"the game server"определяется тем, что он такое, а не тем, кто его хостит: наш парк и собственный хостинг студии — одно направление, и Declaration не несёт маркера, чья это инфраструктура
game-server RPCsрегистрируются в том же роутере: объявить такой и есть зарегистрировать его, и второго способа нет
orderingисполняет middleware сверху вниз, а там, где у шага больше одной реализации, роутер выбирает слева направо по условию, и отвечает версия, помеченная как умолчание, когда не совпало ничего

Что такое ограничение между Hooks и когда оно проверяется.

Что это
a named constraintточка расширения может назвать эффекты, которые она ограничивает: проверка анти-чита обязана предшествовать размещению, чек требует списания в этой точке — и не ограничивать больше ничего
an unnamed effectне ограничен, а не отклонён: потребитель, делающий то, чего никто не предвидел, — это то, ради чего механизм и существует, а закрытый словарь превратил бы это в отказ на регистрации
a violationдефект конфигурации, и отказ называет оба Hooks и ограничение, которое они нарушили, — не предупреждение и не молчаливая перестановка
when it is checkedна каждом действии, способном изменить то, что исполняется в точке: регистрация, деплой, изменение расстановки. Поэтому расстановка, дошедшая до исполнения, уже допущена
never re-checked at run timeэто был бы второй ответ на решённый вопрос, заданный в единственный момент, когда сделать уже ничего нельзя
ВсегдаЧто это
handlersтипизированы на входе и на выходе: никаких dynamic, никаких мешков с контекстом. Handle платформы окружающий, а контекст вызова — вызывающий, триггер, трасса — приезжает разобранным
what an engine build seesEvents, которые сценарий испускает после, потому что override и middleware исполняются на платформе, а движковый рантайм — не место, чтобы их хостить. Именно это имеют в виду вкладки @na у примеров на этой странице, говоря о подписке на получившиеся Events
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.

Возьмите, измените, поставьте. «Проприетарный открытый код» — это рабочий процесс, а не лозунг: собственная логика платформы — это функции, которые можно вытянуть, отредактировать и развернуть заново:

playserv functions pull matchmaking.match     # реализация платформы, исходником
# правка: расширить окно скилла на выходные
playserv push                                 # регистрируется ваша версия; умолчание остаётся запасным

Настройка здесь идёт по одной оси, и это логика: overrides, middleware и версии, о которых эта страница.

Второй оси не существует. Свои поля в Entity платформы добавить нельзя. Игрок, Room, запись Leaderboard и заказ — это системное состояние со своими машинами, а не начало вашей модели данных. Ваши данные — ваша собственная Entity, объявленная в Schema as Code и привязанная к состоянию платформы предикатом: владелец — этот игрок, область — эта Room, — что и оставляет их вашими, когда собственная модель платформы двигается.

Ошибки

  • Имя, которому не соответствует ни один зарегистрированный обработчик, отвечает not found — вызов никогда не отбрасывается тихо оттого, что никто не слушал.
  • Дважды зарегистрированное имя и набор с двумя реализациями, претендующими на одно условие, отклоняются при объявлении набора — на деплое, а не разрешаются подбрасыванием монетки в момент вызова.
  • Отказ Hook несёт собственный код и причину Hook, поэтому «правило игры сказало нет» никогда не приезжает в виде сбоя транспорта.
  • Упавший Hook ведёт себя по своему объявленному kind — fail-open или fail-closed, — и какой из них, было объявлено, а не выведено из случившегося.
  • Hook не вправе менять то, что уже заявлено: ни владельца, ни target, ни Leaderboard или разговор, которому был адресован вызов. Он исправляет входы и возвращает вердикт.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Срок исполнения Hook — за ним поведение при сбое, объявленное его kind.
  • Hooks в одной позиции и реализаций одного метода — регистрация ещё одной отклоняется.
  • Глубина вложенности «Hook вызывает операцию, у которой есть Hooks» — объявленный отказ, а не исчерпание ресурсов.
  • Размер контекста, передаваемого в Hook — обрезание запрещено, поэтому отклоняется регистрация, а не обработчик получает половину контекста.

Путь пользователя

Одна покупка, от клика игрока через настроенную цепочку до пинга в отряд. fraud-check — собственный обработчик студии на Before("grant"), а не модуль платформы; Catalog & Commerce рисует ту же покупку со своей стороны.

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

Цепочку с overrides и middleware можно разрешить и прочитать до того, как что-либо исполнится. Разрешённый порядок осматривается в панели и из кода.

Уроки и рецепты: Leaderboard в Tanks пользуется Hooks этого модуля; ежедневный турнир проходит через этот модуль.

Your game's model

Schema as Code

Объявите модель в коде, отправьте её, получите типы обратно. Дорога разработчика в схему: админ-панель и код пишут одну и ту же модель, а кодогенерация замыкает круг для каждого движка.

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

Когда применять

  • Ваша модель данных должна жить в коде и ревьюиться как код — объявить, schema diff, schema push.
  • Движковые типы не должны расходиться с развёрнутой моделью — schema codegen перегенерирует Unreal C++ и Unity C#.
  • Ломающее изменение должно читаться и отменяться до того, как исполнится, — propose → plan → apply.
  • Вы переиспользуете один набор (Stat, Interactable) между проектами — объявите его один раз пресетом.
  • Не нужно, если оператор просто крутит значения в админ-панели: изменение модели всё равно сдиффится обратно в код.

Кто что делает

ActorНа этой странице
schema-authorобъявляет Entities/parts/enums в коде, диффит и отправляет
operatorсмотрит обзор в панели, предлагает и применяет миграции
ciсборочный конвейер под ключом backend-service: отправляет на мерж и следом перегенерирует движковые типы

Одним взглядом

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # локальные Declarations против развёрнутой схемы
playserv schema push       # с предусловием по Revision — никаких слепых перезаписей
playserv schema codegen    # перегенерировать типы Unreal C++ / Unity C#

Push — явный шаг. При сохранении файла не загружается ничего: Declaration доезжает до развёрнутой модели только тогда, когда исполняется playserv push (или schema push), с вашей машины или из CI, и он несёт ту Revision, против которой был сдиффлен. Правка в панели видна вам ровно так же, как любое другое расхождение: schema diff показывает её против ваших Declarations. Увидеть её — автоматически; сдвинуть в любую сторону — команда, которую вы запускаете нарочно.

Модель

Что несёт Declaration.

НесётЧто это
keyстабильное имя, по которому к нему обращаются. Переименование в коде — это переименование, а не удаление с созданием
kindEntity, part или enum, объявленные в коде
ownership modeseed — код создаёт запись, если её нет, а повторный push значения не трогает, поэтому дальше ими владеет админ-консоль; или managed — код владеет всегда, каждый push приводит значения к объявленным, а правки из админ-консоли отклоняются, а не применяются и теряются на следующем push
presetнеобязательно: переиспользуемый набор — пресет Entity, например stats или world-objects, — объявленный один раз и применяемый как тип. Он не вводит нового рода Declaration, и пресет, которому такой понадобился бы, был бы дырой в контракте, а не пресетом побольше

Что обещает push.

ВсегдаЧто это
matchingпо ключу, никогда по символу: повторный push после переименования символа оставляет одну запись, а не две
idempotencyследует из этого — повторный push не вторая запись
the reportговорит ровно, что изменится, до применения, и что он перезаписал — после
originразличим: запись, созданная push из кода, отличается от созданной где-то ещё
the revisionедет вместе с ним, и push приземляется целиком или никак

Что обещает кодогенерация.

ВсегдаЧто это
regenerationпроисходит после каждого push, а сгенерированные типы никогда не правятся руками: перегенерировать и сдиффить — значит не получить изменений
namingследует за Declaration, где бы оно ни было написано: поле Rarity у Item становится UPSItem::Rarity в Unreal и Item.Rarity в Unity
the two directionsне разветвляются: что объявлено в коде — появляется в панели, а что оператор написал в панели — чисто диффится против кода. Именно это делает schema diff полным ответом, а не половиной

Что обещает миграция.

ВсегдаЧто это
when one is requiredизменение персистентного Declaration, переписывающее существующие значения; ломающее персистентное изменение нельзя опубликовать без неё
what it declaresверсию, предпросмотр, упорядоченное применение, откат при сбое и наблюдаемый исход завершения
coexistenceпока живы две версии данных, чтения и записи объявляют, какие версии принимают, — рантайм никогда не выводит совместимость из имён полей

Ошибки

  • Вызывающий без роли получает forbidden, и развёрнутая модель не тронута: отказ никогда не является частичным push. Это другой отказ, нежели устаревшая Revision, — та даёт precondition_failed и означает, что дифф считался против схемы, которая с тех пор сдвинулась: пересчитайте дифф и отправьте снова.
  • Значение вне объявленной границы отклоняется на записи, а не срезается, и строка сверх объявленной длины тоже. Срезание порождает значение, которое валидно и неверно, а цена ложится на поддержку, а не на вызывающего: отказ стоит одного round trip.
  • Некорректная последовательность UTF-8 отклоняется на записи, а не чинится.
  • Ломающее персистентное изменение без объявленной миграции нельзя опубликовать вовсе.

Ограничения

Потолки, имеющие форму Declaration, проверяются в момент объявления — на деплое или на публикации, — а не при первом использовании, везде, где симптом в рантайме не выглядел бы как отказ. Это правило формулирует глава об ограничениях платформы, и поэтому схема, которая поставилась, — это схема, которая уже влезла. Сами числа приедут с той главой.

Путь пользователя

Одно новое поле, от Declaration в коде до перегенерированных движковых типов.

Your game's model

Entity

Модуль, на который опирается всё остальное. Entity — это объявление схемы, выращенное живыми аспектами: данные 0..*, состояния 0..*, RPC 0..*, Events 0..*, Hooks и история изменений. Map привязывает препятствия к Entities, Collision привязывает аспект трансформа, Stats и есть пресет, World Objects — пресет плюс машина состояний.

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity монтируется в корень, поэтому room.Entity<Door>(id) и playserv.Entities<KeyDef>() сидят прямо на корне, а не за пространством имён. Он строится на трёх Primitives — Events, RPC и Data & Subscriptions — и больше ни на чём. Collision, Locomotion и Prediction & Lag Comp стоят над ним: каждый привязывается к одному аспекту, а не ко всей Entity, — поэтому контакт может запустить переход, а модуль Collision ничего не знает про разрешения.

Когда применять

  • Объекту мира нужно поведение, а не только поля, — машины состояний, RPC с правами и Events на одном Declaration.
  • Двери, ловушки, подбираемое: переходы обязаны срабатывать от Events клиента, контактов Collision или порогов Stat без кода в Room'е.
  • Хочется создавать игровые объекты одной строкой — применяйте или выводите пресеты вроде world-objects.
  • В споре нужно точное состояние мира в момент выстрела — прочитайте экземпляр на прошлом sim_time, внутри объявленного окна.
  • Не нужно, когда у вещи нет личности: значение, которое живёт только внутри чего-то другого — например, текст на табличке двери, — это поле в аспекте, а не отдельная Entity. Всё, к чему обращаются, является Entity: Data & Subscriptions — механика под ней, и никакой путь через таблицу её не обходит.

Кто что делает

ActorНа этой странице
schema-authorобъявляет Entities, аспекты, машины состояний, пресеты
every actorзапрашивает, подписывается, зовёт RPC у Entity, читает состояние

Одним взглядом

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

Объявленная единица — аспект, а не поле: motion несёт собственный ритм и собственную маску, info несёт другие, а поле принадлежит ровно одному из них. Именно это позволяет пресету прицепить целую группу разом и позволяет Collision привязаться к единственному аспекту, несущему трансформ, не видя больше ничего на Entity.

Машиной в этом блоке правят три правила:

  • Имя через точку вкладывает на один уровень. open.blocked ассоциируется с open само по себе, поэтому переход close_requested, объявленный на open, действует внутри него без повторения. Пока машина стоит в open.blocked, она находится в open: проверка состояния на open истинна, а OnEntered("open") сработал на входе и для подсостояния повторно не срабатывает.
  • Таймер, объявленный на состоянии, идёт только пока это состояние текущее. Уход из opening сбрасывает его AfterSeconds, а повторный вход запускает свежий.
  • Страж — объявленный предикат над собственными полями Entity, на том же языке, что предикат строки в доступе. Логика, которой нужен код, — это Hook, а не страж.

Пути полей — написание отправленной модели. Предикаты и пути запросов называют поля так, как их отправило Declaration — motion.jammed, gate.state, info.name, — как бы каждый биндинг ни записывал их у себя.

Кто вправе вызвать RPC. RPC у Entity называет нужное ему право так же, как любая operation: atom права (entity × execute) плюс предикат строки, говорящий, какие экземпляры он покрывает (оба принадлежат Access & Roles).

В DeclarationЧто это значит
caller in entity.roomлюбой Actor в Room'е, где стоит дверь, с какой бы сборкой он ни пришёл. Близость сюда не входит: насколько близко надо стоять, чтобы получать дельты двери, — правило Visibility на политике синхронизации аспекта, то есть полоса, а не разрешение, и расширение обзора никогда не расширяет право
Actorличность вызывающего, тот же объект, что возвращает whoami
caller.Inventoryхэндл Inventory для этого игрока, доступный везде, где смонтирован этот модуль

Реальным Declaration делает playserv push. Этим шагом владеет Schema as Code: он диффит ваши Declarations против развёрнутой модели, несёт Revision, против которой был сдиффлен, и отказывает вместо перезаписи, если развёрнутая схема сдвинулась. Повторный push, который сломал бы уже живые экземпляры, идёт через propose → plan → apply, поэтому план читается до того, как что-либо изменится.

На клиенте Entity и есть API:

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

Сигнал locked — собственный Event двери: его target взят из Declaration — этот экземпляр, — поэтому слышат все подписанные на дверь, и никакой список получателей с отправкой не едет.

Модель

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

Танк, дверь, полоска Stat и квест — все они Entities. Различаются они тем, какие аспекты несут, и больше ничем; именно это позволяет каждому другому модулю строиться на этом.

Что объявляет Entity.

ОбъявляетЧто это
aspectименованная группа полей, объявленная целиком, со своей политикой синхронизации и своей маской доступа. Entity несёт несколько, и ни одно поле не входит в два
state machineсостояния, вкладываемые на один уровень, переходы и стражи; несколько на Entity
triggerто, что запускает переход, — четыре источника ниже
entity RPCглагол, торчащий из Entity, объявленный внутри вида вместе с нужным ему атомом права
entity eventсигнал, который Entity испускает, доставляемый тем, кто подписан на этот экземпляр
hookдо и после, на операциях с данными и на переходах, разворачивается облачной функцией. Extensibility объявляет порядок, форму вердикта и то, что делает сбой
history trackдержит ли вид мгновенное окно вообще
refссылка на другую Entity, держащая её id и никогда её key, поэтому переименование ключа никогда не ломает ссылку. Include втягивает её вместе со страницей

Что запускает переход — и ни один из четырёх не является вашим кодом, исполняющимся в Room'е.

ИсточникКак запускает
client event or RPCлюбой объявленный — RequestOpen выше запускает open_requested
collisionконтакт или вход в объём-триггер — ловушки, нажимные плиты — через аспект, к которому привязывается Collision
data thresholdобъявляется на Stat, 0 HP → death, обеспечивается порядком Hooks, а не кодом в Room'е
timeAfterSeconds на состоянии — объявленный триггер, а не корутина: он идёт по часам симуляции Room'ы, продвигается с sim_time, стоит, пока Room не симулируется, а удаление экземпляра заканчивает его машины вместе с их ожидающими таймерами

Что может выборка.

ОсьЧто допустимо
filter и sortтолько объявленные поля — handle таблицы не существует, а выборка адресуется по Entity и ограничена Room'ой или проектом
includeобъявленный ref, втягиваемый вместе со страницей
pagingпо непрозрачному курсору: не смещение, не идентификатор строки, и его смысл не переживает смену версии. Возвращайте его обратно, никогда не разбирайте
accessпредикаты применяются до разбиения на страницы, поэтому страница никогда не несёт дыр там, где были бы скрытые строки
liveподписка на выборку держит её живой: участники входят и выходят по мере изменения их данных
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

Что верно про любую Entity.

ВсегдаЧто это
a selectionэто набор Entities, а не Group: участники Group'ы — Actors, и она существует, чтобы один сигнал дошёл до всех, тогда как выборка — это чтение, которое просто остаётся живым
a transition requestостаётся запросом: исполняются стражи машины, исполняется предикат строки, а переход, которого машина не объявляет, отклоняется с invalid_state_transition, а не игнорируется тихо
pushing past a guardэто другая операция с другим атомом — entity × administer, которого ни один клиентский ключ по умолчанию не держит
historyэто только мгновенное окно: недавние состояния, индексированные по sim_time, ограниченные объявленной глубиной, а чтение вне его отклоняется, а не отвечает ближайшим значением. Ветвящаяся история — альтернативные линии, отмена, переигрывание целого матча — вне объёма модуля, потому что ей пришлось бы обещать воспроизводимые значения с плавающей точкой, а правила типов их не обещают
the boundary of a changeэто одна Entity, и на ней «всё или ничего» заканчивается. Две Entities, изменённые одним вызывающим — списать кошелёк, добавить предмет, — могут наблюдаться применёнными наполовину. Поэтому пара, которая обязана появляться вместе, — это не две Entities: держите оба значения в одном экземпляре, и границу отработает он. Потянуться к Hook, чтобы «сделать атомарно», не помогает: Hook исполняется вокруг одного изменения, а не поперёк двух
a declared method with no implementationэто законченное состояние, а не полунастроенное. Вызов отвечает вердиктом, несущим машиночитаемую причину «нет реализации», — не отказом и не успехом с пустым результатом. Отказ означал бы, что вызов делать не следовало; здесь следовало, и не случилось только решение
a name never declaredэто другой исход, нежели имя, объявленное без реализации: первое — отказ валидации, второе — вердикт, и код их различает
an unimplemented callне исчезает: то, что кто-то его вызвал, наблюдаемо для студии. Какую форму принимает наблюдение, намеренно не входит в контракт, поэтому стройте на факте наблюдаемости, а не на строке лога
creating an instanceнесёт атом entity × write: облачная функция, выделенный сервер и master-client держат его по умолчанию, а обычный клиент — только там, где его даёт роль, и это в каждом биндинге, а не только в Unreal

Пресеты. Пресет — именованный набор аспектов, машин, Hooks и лимитов, применяемый к виду. Новых понятий он не добавляет: всё, что приносит пресет, вы могли бы объявить руками, — поэтому пресет, которому нужен новый род Declaration, это дыра в модели, а не пресет побольше. Поставляется пять: stats, abilities, projectiles, drops, world-objects, и каждый целиком объявлен в Entity Presets. Студия выводит из них собственные, в коде или в панели: Crate — это world-objects плюс stats:

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Ошибки

  • Несуществующий экземпляр и экземпляр, скрытый предикатом, оба отвечают not found — поэтому отказ никогда не сообщает вызывающему, что нечто существует, но не его.
  • Необъявленное поле, включая вложенные, и обязательное поле без значения — отказы валидации, называющие поле.
  • Переход, которого машина не объявляет, отклоняется как invalid_state_transition, а не игнорируется тихо. Протолкнуть машину мимо её стражей — другая операция с другим атомом: administer, которого ни один клиентский ключ по умолчанию не держит.
  • Расхождение версии — сбой предусловия: перечитайте и решите заново.
  • Занятый ключ — конфликт.
  • Запись от имени игрока, не называющая игрока, — отказ валидации, а не запись, приписанная никому.
  • Отсутствие разрешения отвечает forbidden, и чтение с записью различаются.
  • Чтение истории вне мгновенного окна отклоняется, а не отвечает ближайшим значением: «нет данных на этот Tick» и «вот примерно такое значение» — разные факты.
  • Хранимый экземпляр сверх лимита размера — конфликт, называющий провинившееся поле и измеренный размер; потолок достигается накоплением, поэтому приближение к нему наблюдаемо до падающей записи.

Ограничения

Каждый лимит объявляется вместе с тем, что происходит на его границе. Числа задаются на проект; поведение ниже зафиксировано уже сейчас.

ЛимитНа границе
аспектов на вид · машин на вид · глубина вложенности внутри аспектаDeclaration отклоняется на playserv push, никогда не обрезается тихо
размер хранимого экземпляразапись отклоняется как конфликт с указанием поля и измеренного размера; приближение к лимиту наблюдаемо до отказа
размер страницы выборкистраница режется по потолку, и «есть ещё» остаётся истинным — короткая страница, выглядящая последней, вам не достанется
окно мгновенной историичтение вне окна отклоняется, а не отвечает ближайшим значением
частота изменений одного экземпляраотказ по рейт-лимиту с указанием, сколько ждать

Путь пользователя

Одна дверь, от Declaration до звона, который слышит игрок.

Your game's model

Наследование и композиция

Модули строятся друг на друге, и ничего из этого не наследование классов. Базового модуля, от которого наследуются, нет, и иерархии, которую расширяют, тоже — модули образуют граф. Эта страница о том, что здесь честно означает «наследование», и о шести механизмах, которые делают работу вместо него.

Что здесь означает наследование

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

Это слово покрывает четыре разных механизма, и их стоит развести по именам.

  • RPC у Entity — часть Entity. Они не существуют больше нигде: ни на каком-то родителе, ни в общем реестре. Если метод принадлежит двери — он на двери. См. Entity.
  • Пресет — именованный набор, а не базовый класс. Stats, Abilities, Projectiles, генераторы дропа и World Objects — это пресеты entity, наборы аспектов, которые применяет вид Entity; именно поэтому они живут на одной странице Entity Presets, а не пятью модулями. Применение пресета добавляет аспекты; ваш тип оно ни подо что не подкладывает.
  • Переопределение шага платформы — атрибут на вашей замене. Вы не наследуете наш класс; вы объявляете свой, а версии выбираются по условию, с умолчанием платформы в запасе. См. Extensibility.
  • Модуль заимствует другой через декоратор, который сужает или обогащает заимствованный интерфейс, а реализация за ним сменяема. Разобранный случай — чат внутри Room'ы, на Groups.

И чем это не является: иерархии классов у модулей нет, потому что дерево допускает только ветви, а настоящие фичи их пересекают. Matchmaking резервирует места в Rooms; дроп размещает предметы через Map; Leaderboard кормится Hook на закрытие Room'ы. Это граф, и это намеренно.

Шесть механизмов

Каждый объявляется атрибутом рядом с тем, что он складывает, — то же декларативное правило, что управляет и всем остальным в SDK.

Точки монтирования, как в файловой системе. Модуль монтируется в корень, складывая несколько интерфейсов в одну поверхность, — или в пространство имён. Второй модуль, претендующий на занятую точку монтирования, отклоняется в момент монтирования, а не на первом вызове. Механизм — на Под капотом.

Лексическая видимость. Видимость имён следует за вложенностью: глобальное Declaration видно внутри модуля, локальное наружу не течёт никогда. Что модуль испускает — отдельный вопрос, и он объявлен в его собственном контракте: модуль знает только те Events, которые объявил сам или которые были зарегистрированы у него.

Инкапсуляция как контракт. Модуль никогда не знает, кто его зовёт и зачем. То, что он выставляет и что испускает, — вся его публичная история, и ничто в вызывающем не меняет его поведения, кроме выдачи вызывающего.

Переиспользование через декоратор и инверсию управления. Модуль ссылается на другой через декоратор, а не залезая внутрь, и реализация за интерфейсом сменяема. Именно этот механизм позволяет заменить один наш модуль на свой так, что зависящие от него модули этого не замечают.

Declarations растят API. Объявите Event на Group'е — и появится group.Send.ChatMessage(…) со своим контрактом; объявите данные members — и появится типизированный геттер. Declaration и есть вход кодогенерации; поэтому же версионируется Declaration, а не сгенерированный код.

Три оси адресации из одного модуля. Все экземпляры, один экземпляр и администратор одного экземпляра — это три разных API, а не одно API с флагом. Целиком изложено на Groups.

Неявные аргументы и почему это не магия

Внутри Entity вы никогда не передаёте эту Entity. Получатель, вызывающий и окружающий контекст привязываются автоматически, потому что все три уже определены тем, где сделан вызов и кто его сделал: передавать их означало бы просить вас повторить то, что платформа уже знает, и давать вам шанс сказать это неверно. Механизм — на RPC.

Your game's model

Entity Presets

Пресет — именованный набор аспектов Entity — данные, состояния, RPC, Events, Hooks, — упакованный под один игровой случай. Пресет применяют, его числа крутят или выводят свой. Применение добавляет аспекты вашему типу; ваш тип оно ни подо что не подкладывает — пресет не модуль, и наследовать в нём нечего. Stats, Abilities, Projectiles, таблицы дропа и World Objects — это пять пресетов, а не пять подсистем: то же Declaration, та же синхронизация, тот же порядок Hooks.

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

Когда применять

  • Вещь в вашей игре несёт числа, которые срезаются по границам, восстанавливаются и запускают переход на своих границах.
  • Действию нужны стоимость, откат, фазы и эффекты, достижимые из одного клиентского глагола.
  • Нечто уходит в полёт, и его попадание должно судиться честно для стрелка с лагом.
  • Лут обязан приходить из взвешенных шансов, которые переигрываются в точности, когда игрок спорит о дропе.
  • На карте есть мебель — двери, кнопки, ловушки, разрушаемое — с состояниями, которые обязаны пережить вход посреди раунда.
  • Пресеты не нужны, когда Entity — это просто синхронизируемые данные. Объявите поля и остановитесь.

Кто что делает

ActorНа этой странице
schema-authorобъявляет Stats, Abilities, Projectiles, таблицы дропа, World Objects
room-ownerкрутит числа пресетов, бросает таблицы дропа, создаёт World Objects
playerприменяет способности, стреляет, подбирает лут, взаимодействует с объектами

Одним взглядом

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Модель

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

Пресет не вводит новых понятий. Всё, что он добавляет, выразимо средствами, которые Entity уже даёт: аспекты, машины, Hooks, персистентность. Пресет, которому понадобился бы новый род Declaration, был бы дырой в контракте, а не поводом сделать пресет побольше. Это и есть весь тест на то, место ли чему-то здесь.

Что даёт каждый пресет и где он крутится.

ПресетЧто даёт ему контрактГде крутится
Statsаспект числовых характеристик с границами, восстановлением и модификаторами плюс Hook на достижение порога — 0 HP становится переходом машины, а не if в вашем кодеDeclaration поля; числа остаются правимыми на живую в панели
Abilitiesаспект набора способностей, машину фаз применения, стоимость и откатDeclaration способности
Projectilesтип с персистентностью runtime, аспект баллистики и Event попаданияDeclaration снаряда — замена одного атрибута меняет модель полёта
Dropsаспект таблицы дропа с весами и Hook после смертизаписи таблицы и их веса
World Objectsмашину состояний интерактивного объекта и аспект условия взаимодействияDeclaration пресета или на экземпляр при создании
Inventoryвладеемый тип с ref на предмет каталога, аспект стека с инкрементом и потолок на владельца с объявленным переполнениемDeclaration типа

В таблице по одной строке на preset, потому что одна строка — это всё, чем они различаются. Общее у них ниже, а Inventory — единственный, у кого есть ещё и своя страница.

Что верно про любой пресет.

ВсегдаЧто это
where it sitsна Entity, аспектами: его данные синхронизируются как любые другие, его состояния — состояния Entity, его RPC — RPC у Entity, а его Hooks исполняются в порядке Hooks у Entity
tuningэто живая конфигурация, а не передеплой, — поэтому панель показывает границу Stat, откат и вес дропа в одном дереве
declaring oneакт схемы, а не игровой вызов, — поэтому его отказ другого рода, нежели отказы, с которыми встречается игрок, и оба в разделе «Ошибки» ниже
deriving your ownэто композиция, а не наследование классов: Crate — это world-objects плюс stats, и выведенное всё равно остаётся аспектами на Entity

Ошибки

  • Объявить Stat, Ability, Projectile, таблицу дропа или World Object — акт схемы: fn или adm. Игрок или клиентский ключ, попытавшийся это сделать, получает forbidden, и ничего не объявляется и не объявляется наполовину. Это другой отказ, нежели те, с которыми игрок встречается внутри вызова, который ему было дозволено сделать, — на откате, нечем платить, отсутствует item:key.bronze, — и каждый из них несёт свой код.

Остальные отказы пресета — это отказы Entity: пресет не вводит понятий, значит не вводит и отказов, и повторение их здесь дало бы читателю два места для проверки одного ответа. Два относятся к самим пресетам:

  • Заполненный наполовину пресет — сбой валидации на деплое. Пресет несёт связный набор: половина Declaration отклоняется до поставки, а не ведёт себя странно в матче.
  • Пресет нельзя пометить свойством, которому противоречит его собственная механика: аспект, для которого у клиента нет правил, нельзя объявить предсказуемым, и это тоже ловится на деплое.

Ограничения

Потолки принадлежат Entity — аспектов на тип, машин на тип, размер хранимого экземпляра, частота изменений одного экземпляра. Единственный, который объявляет сам пресет, — потолок на владельца, который несёт владеемый пресет, с одним из трёх поведений на границе и без значения по умолчанию: refuse · redirect в объявленную корзину владельца · discard with event. Числа приедут с главой об ограничениях платформы.

Путь пользователя

Один снаряд, от нажатия спуска до ящика у ног стрелка. Участвуют четыре пресета — ability, projectile, stat и drop-table, — и ни один из них не модуль, который вы монтируете.

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Rooms

Room — это игровая сессия; платформе всё равно, что её хостит. Одна абстракция покрывает выделенный сервер на матч, одну большую общую карту, разрезанную на логические слои, Room под master-client и мини-игру, хостящуюся на бэкенде. Внутренности Room'ы наши; вы ведёте Room'у снаружи.

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • В вашей игре есть сессии — матчи, лобби, подземелья, гонки, — и что-то обязано владеть их жизненным циклом, членством и переподключениями.
  • Вы хостите на выделенных серверах, на master-client игрока или на самом бэкенде, и игроков надо туда маршрутизировать.
  • Одна общая карта обязана вести много логических сессий — слои, ограниченные Visibility.
  • Игроки входят посреди сессии и обязаны увидеть текущую правду — состояние Room'ы на входе, затем живой трафик.
  • Оборванное соединение не должно стоить места — льготное окно шаблона (45 с в battle) возобновляет то же членство.
  • Не нужно, если фича — чистый запрос/ответ над записями: Data & Subscriptions её уже покрывает.

Кто что делает

ActorНа этой странице
room-ownerрегистрирует Rooms по всему процессу; на одном экземпляре Room'ы — админский интерфейс на экземпляр: правит живую конфигурацию, кикает, запирает, вещает, распускает
entry-validatorпринимает или отклоняет запросы на вход с кодом и причиной
room-visitorлистает, входит с данными, переподключается в льготном окне, выходит
spectatorвходит, не участвуя в состязании; получает вещание и живой трафик
match-organizerрезервирует места, которые засчитываются в вместимость; бронь истекает по сроку шаблона (90 с в battle)

Одним взглядом

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Где бы он ни был написан, отправленный шаблон версионируется и правится в панели, поэтому live-ops перенастраивает тип Room'ы без передеплоя движка. Хостинг Rooms, построенных из него, — та же поверхность, до которой добирается другая роль, и её целиком держат и выделенный сервер Unreal, и master-client: зарегистрировать, хостить несколько на процесс, править живую конфигурацию, кикать, публиковать, распускать.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Клиент:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

Модель

Room — это Group с правилами, и это не Entity. Членство приходит из Groups; её системное состояние живёт на уровне платформы, а состояние игры — в Entities, ограниченных ею. И никакой потребительский код не исполняется внутри Room'ы — ни в одном из режимов авторитетности.

Что объявляет тип Room'ы.

ОбъявляетЧто это
capacityв местах, и место — это единица вместимости, отделённая от членства: его можно забронировать до входа и удерживать сквозь бездействие. Бронь ограничена по времени, с объявленным сроком, после которого место освобождается без входа
visibilityперечислимая · по имени или коду · скрытая
creation modeодин из трёх, и режим on first join обязан объявить Hook инициализации
two independent timeoutsтаймаут бездействия — сколько участнику дозволено молчать; и TTL пустой Room'ы — сколько живёт Room, в которой никого нет. Два разных вопроса — значит два Declaration
rejoin windowвнутри него возврат восстанавливает то же членство и то же место, а не заводит нового участника
authority modeour simulation или external authority, и значения по умолчанию нет
trust in a reported outcomeдля внешнего авторитета: принять · проверить Hook'ом · не принимать. И снова без умолчания
behaviour when the host dropsвыждать льготное окно · закрыть Room'у · допустить замену
map instance and world strataнеобязательно: какой экземпляр она занимает и какие слои внутри него
room-scoped entitiesкакие Entities студии имеют область Room'ы — выражается предикатом, а не новым механизмом

Две машины.

У чегоСостояния
Roomcreated → open → closed → torn down, где torn down терминально, а closed означает «новых входов нет», а не «исчезла»
членствоactive ⇄ inactive → departed, и departed терминально для этого членства

Что верно про любую Room'у.

ВсегдаЧто это
losing a connection and leavingразные события, и исход окна наблюдаем: «вернулся» и «окно истекло» различимы, поэтому клиента никогда не оставляют гадать, что из двух случилось
no replayпереподключение возобновляется из состояния сессии; Events разрыва модуль не обещает
a spectatorне вырожденный участник: присутствует, не занимает места и не входит в состав, к которому обращаются как к «игрокам», — иначе каждая операция над составом несла бы условие
no in-room rolesвладелец Room'ы — это Actor, держащий право (Access & Roles), а не звание в списке участников
presenceимеет историю, состав — нет: кто вошёл, отвалился, вернулся и ушёл, сохраняется; изменения состава — не вторая история
the interfaceпринадлежит конкретной Room'е: вы обращаетесь к этой Room'е, а не только к её типу
three axes, not twoAPI поверх всех Rooms (листать, регистрировать, перечислять); API на Room'у, который зовёт любой участник (войти, выйти); и админский интерфейс на экземпляр — кикнуть, запереть, поправить конфигурацию, закрыть, распустить эту Room'у, — открытый тому, кто держит админскую или хостовую роль для этого экземпляра, а не членством
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

Два режима авторитетности.

РежимКто ведёт Tick
our simulationнаша реализация Room'ы и её модули
external authorityпроцесс, исполняющий наш SDK, находящийся в Room'е под авторитетной ролью: игровой сервер студии или клиент игрока в роли master-client

Что решает режим и чего не решает.

Что это
the lineпроводится ролью, а не тем, чей это процесс. Выделенный сервер — тот же клиент без отрисовки; от машины игрока его отделяет доверие, а не устройство. Поэтому же peer-to-peer не нуждается в третьем режиме: это Room в режиме внешнего авторитета, чей авторитет — клиентский хост
what is identicalправила входа, присутствие, переподключение и каждое Declaration — во всех трёх. Отличается только то, какой процесс держит авторитет и сколько его этому процессу выдано
the room does not moveникакой потребительский код не исполняется внутри Room'ы ни в одном режиме, и состояние сессии и персистентное состояние в обоих остаются у нас. Master-client — это участник, держащий авторитетную роль: Tick вычисляется там, Room там не живёт
trust in the outcomeотдельное Declaration на типе Room'ы — принять · проверить Hook'ом · не принимать, и без умолчания, — а не свойство режима

Хостинг Room'ы.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
ВсегдаЧто это
the handleтот же объект, которым пользуется клиентская вкладка: хостового бутстрапа нет и серверного handle нет. Он отвечает на эти вызовы, потому что роль Actor включает их в рантайме, — выделенный сервер или master-client, исполняющийся под ключом хоста (Access & Roles)
registrationпринимает ключ идемпотентности, потому что таймаут на ней иначе невосстановим: повторите вызов с тем же ключом — и получите ту же Room'у, а не вторую, чьего адреса никто не знает

Вход, присутствие и переподключение.

Что это
entryэто проверяемый запрос: входящий подаёт данные на входе, а Hook входа принимает или отклоняет с кодом и причиной. Именно из этих данных участник себя инициализирует — в battle набор снаряжения, занесённый на входе, это то, с чем спавнится Tank участника
seats and reservationsMatchmaking забирает место на срок брони шаблона — 90 секунд в battle, — и клиент затем входит напрямую. Брони засчитываются в вместимость, и истечение освобождает место с Event, а не молча
a drop is not a leaveотвалившийся участник держит своё место льготное окно (45 с в battle) и переподключается в то же членство; истечение делает это выходом, и Event несёт, что из двух это было. Вернувшемуся на секунду позже говорят, что Room жива, а членство нет, — намеренно другой ответ, нежели «нет такой Room'ы»
late join is state, not a journalвходящий получает текущее состояние Room'ы, а затем живой трафик. Events, отправленные, пока его не было, не переигрываются, и пропущенные вернувшимся участником тоже: всё, что обязано пережить разрыв, — это состояние. Мина, которую поставил игрок, — это Entity в области Room'ы, а не сообщение MinePlaced, которое кто-то обязан поймать

Ошибки

  • Room, которой не существует или которую скрывает предикат, и Room в состоянии torn down отвечают not found — отказ никогда не выдаёт Room'у, которую вам не положено видеть.
  • Исчерпанная вместимость — конфликт, и забронированные места считаются занятыми; стоит повторить, когда место освободится. Закрыта для входов — тоже конфликт, стоит повторить, если откроется.
  • Вход, отклонённый правилом, — конфликт; отклонённый Hook'ом несёт собственные код и причину Hook, поэтому «правило игры сказало нет» никогда не приезжает в виде сбоя транспорта.
  • Окно возврата истекло — конфликт: входите новым участником, на новое место.
  • Просроченная бронь — конфликт: возьмите новую.
  • Экземпляр карты, который недоступен или не существует, — отказ валидации.
  • Перемещение, отклонённое целевой Room'ой, — конфликт, и что делать, зависит от его причины.
  • Создание сверх лимита Rooms отвечает рейт-лимитом или конфликтом — смотря какой это был лимит.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Вместимость Room'ы — вход отклоняется как конфликт, забронированные места считаются занятыми.
  • Rooms на проект — создание отклоняется как конфликт.
  • Rooms на Actor — создание отклоняется, и уже созданные Rooms никогда не распускаются, чтобы освободить место.
  • Частота создания Rooms — рейт-лимит со сроком.
  • Таймаут бездействия участника — принудительный уход с Event и объявленной причиной.
  • TTL пустой Room'ы — роспуск с Event; отключается на типе персистентной зоны.
  • Срок брони места — освобождение с Event.
  • Rooms на одном экземпляре карты — создание на занятом экземпляре отклоняется, если тип не объявил совместное занятие.
  • Размер payload у Event Room'ы — публикация отклоняется до отправки, никогда не обрезается.

Путь пользователя

Один матч на выделенном сервере, от входа игрока до HUD, показывающего, кто присоединился.

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

Кто что видит и какая машина это ведёт

Два вопроса, которые звучат как один. Кто что видит — про клиента: какой срез состояния Room'ы доезжает до какого игрока. Какая машина это ведёт — про хост: какой процесс владеет Entity и какой будет владеть следующим. Слово, которое их смешивает, — replication: в игровом движке оно обычно называет первый вопрос, а здесь — второй.

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
Вы имеете в видуЧитайте
какой клиент получает какое состояние и сколько егоVisibility, вместе с Data и Prediction
какая машина владеет Entity и что происходит, когда она умираетWhat Survives Losing a Host

Они объявляются в двух разных местах

Ни то ни другое не настраивается в рантайме, и общей Declaration у них нет.

Объявляется наЧто называет
кто что видитаспекте — Data, Visibilityпредикат видимости, потолок объектов и его порядок, какие соседние области видны, и режим доставки
какая машина это ведёттипе Room'ы — Roomsрежим авторитета, насколько внешнему авторитету верят про исход, и поведение при падении хоста

Различаются они и тем, что происходит, если не сказать ничего. Аспект без собственного правила видимости доставляется в общем пакете — это умолчание, и для маленькой Room'ы оно верное. Тип Room'ы, не назвавший режим авторитета, отклоняется: умолчания нет, потому что выбрать между нашей симуляцией и внешней за вас никто не может.

The live game

Visibility

На сорока игроках снимок всей Room'ы нормален. На двухстах — нет. Зона видимости решает, кто что получает, объявленным предикатом, а не тумблером, который вы щёлкаете на объекте. Широковещание и пакеты на Actor — два объявленных режима доставки одной модели, поэтому переход между ними это конфигурация, а не переписывание. Это оптимизация канала, а не разрешение — за ним см. Access & Roles.

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

Одна объявленная модель — предикат, слои, ярусы детализации — читается двумя способами. Переход между ними это конфигурация, а не переписывание, потому что оба являются прочтениями одного Declaration.

ШироковещаниеПакеты на Actor
Отправляетвсю Room'у всемкаждому игроку только тот срез, который выбирают его правила
Подходитмаленькой Room'е; это значение по умолчаниютолпе, где размер пакета обязан оставаться предсказуемым
Читает Declarationодин раз, на Room'уна каждого Actor

То, что не должно утечь, отсутствует в пакете, а не спрятано на клиенте: не отправлено вовсе — и это делает его свойством безопасности, а не полосы.

Когда применять

  • Ваши Rooms переросли широковещание на всю Room'у — двумстам игрокам нужны поклиентские потоки окрестностей, а не каждая Delta.
  • Состояние не должно утекать: туман войны и поля только для владельца должны быть не отправлены, а не спрятаны на клиенте.
  • Несколько сессий делят одну карту и не должны видеть друг друга — слой это ещё один предикат.
  • Размер пакета обязан быть предсказуем в толпе — ограничьте число объектов и объявите порядок, чтобы «ближайшие N» были обещанием, а не случайностью плотности.
  • Игрок на границе обязан видеть за неё — объявите, какие соседние области видны, потому что по умолчанию видна только своя, и граница иначе читается как стена пустоты.
  • Не нужно, если Room маленькая: режим доставки общим пакетом её уже покрывает.

Кто что делает

ActorНа этой странице
schema-authorобъявляет предикат видимости, потолок объектов и его порядок, какие соседние области видны и режим доставки
anyподписывается и получает то, что зона допускает; может опустить потолок объектов для себя в объявленных границах

Одним взглядом

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

Модель

Что объявляет правило видимости.

ОбъявляетЧто это
predicateсамо правило, на том же языке предикатов, что предикаты доступа и стражи переходов. Радиус, экземпляр карты, команда и владение — частные случаи предиката, а не отдельные механизмы: такой язык в контракте ровно один
object cap и его порядокправило может ограничить число объектов, и тогда порядок отбора объявляется, а не выводится: «ближайшие N» — это предикат плюс упорядочивание по расстоянию плюс потолок. Один предикат этого выразить не может, потому что предикат отвечает «подходит ли эта строка», а не «кто из подходящих ближе»
neighbouring areasвидны ли объекты из соседней Room'ы или соседнего экземпляра карты и какие именно. Никогда неявно: без Declaration область — та, в которой находится получатель
delivery modeshared packet — одно и то же всем, дёшево по CPU; или per-actor packet — каждому своё по его зоне, дорого по CPU и необходимо на больших населениях

Что верно про любую зону.

ВсегдаЧто это
visibilityне разрешение: то, что скрывает зона, может быть доступно по разрешению, и наоборот. Первое — оптимизация канала, второе — безопасность, а смешение означает, что настройка тумана войны молча расширяет разрешения или ACL используют ради экономии полосы и права начинают зависеть от расстояния
the recipientможет опустить потолок: внутри объявленного максимума и никогда ниже объявленного минимума, потому что предикат одинаков для всех, чей контекст совпал, а размер пакета — проблема получателя
truncationнаблюдаема: получатель узнаёт, что пакет резали и по какому порядку. Молчаливое обрезание запрещено — оно неотличимо от того, что объектов больше нет
degradationобъявлена: когда бюджет на сборку пакетов на Actor кончается, платформа откатывается к общему пакету как объявлено, а не начинает произвольно терять получателей: хуже, но известным способом, вместо утечки, неотличимой от бага игры
packet shapeслабое обещание: размер и состав пакета не должны бы позволять вывести существование скрытых объектов, и это намеренно слабее, чем MUST, — полностью спрятать метаданные потока на реальных объёмах недостижимо. Там, где утечка существования важна, пользуйтесь разрешениями, а не зоной

Ошибки

  • Необъявленный target подписки — отказ валидации.
  • Отсутствие права на подписку отвечает forbidden или not found в зависимости от того, секрет ли само существование target: отказ не должен выдавать того, в чём он отказывает.
  • Позиция возобновления, которая не разбирается, — плохой запрос, а не молчаливый рестарт с «сейчас».
  • Подписка, закрытая платформой, и исчерпанное число подписок — оба конфликты.
  • Расширение обзора — это fn. Выдача расширенного обзора и задание ярусов детализации сессии игрока отвечают forbidden, и её обзор не меняется: клиент-наблюдатель не может расширить собственную выдачу.
  • Экземпляр, который обзор вызывающего исключает, отвечает not found — тем же, чем и несуществующий: forbidden подтвердил бы, что за стеной что-то стоит.
  • Чтение стоимости пакета на Actor — это fn adm: облачная функция или панель, но никогда клиент, спрашивающий, сколько стоит за ним наблюдать.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Стоимость пакета на Actor — при исчерпании объявленная деградация к общему пакету с уведомлением, а не произвольная потеря получателей.
  • Объектов на правило — ограничены объявленным порядком и наблюдаемым флагом обрезания.
  • Подписок на Actor — новая отклоняется, существующие продолжаются.
  • Размер Delta — Delta разрезается, а не обрезается, и разрез наблюдаем.
  • Частота отправки — верхняя граница, а не гарантия.

Путь пользователя

Правило радиуса превращает Room'у на 200 игроков в поклиентские потоки окрестностей.

«Кто это видит?» и «что они видят?» — оба запрашиваемы, потому что дорога та отладочная сессия, в которой вы не можете на них ответить. Стоимость пакета на Actor — полноправное чтение, и в коде, и в панели.

The live game

Что переживает потерю хоста

Хост умирает посреди матча. Матч — нет. Эта страница про второе значение слова «репликация» — какая машина владеет Entity и какая будет владеть следующей. Первое значение, какой клиент получает какое состояние, — это Visibility вместе с Data & Subscriptions и Prediction & Lag Comp. Кто что видит и какая машина это ведёт — место, где эти двое разводятся.

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

Состояние Room'ы не копируется между хостами

У Entity ровно один владелец за раз, и никакая вторая машина не держит живую копию, готовую перехватить.

Две копии, принимающие один и тот же выстрел, обязаны были бы договориться о порядке, в котором приземлились два выстрела. Договариваться о порядке тридцать раз в секунду между машинами — это консенсус, а консенсус кладёт задержку ровно туда, где игра её не потерпит. У единственного владельца такой проблемы нет, и каждый механизм ниже существует, чтобы сделать единственного владельца переживаемым, а не чтобы его обойти.

Что реплицируется — так это присутствие: какой Actor на каком узле. Это маленький и медленно меняющийся факт, поэтому маршрутизация может знать его повсюду, не платя за согласие о чём-либо движущемся.

Объявленное состояние хранится вне хоста

Объявленное состояние не является частным делом процесса, который его держит. Оно снимается с объявленным интервалом, поэтому замена может продолжить с последнего снимка, когда предыдущий хост перестаёт отвечать, а игрок заходит заново через обычное льготное окно Rooms.

Отсюда три следствия, и это честная форма происходящего:

  • У замены состояние целиком, но по состоянию на снимок. Полное, а не текущее. Отказ стоит игры между последним снимком и потерей, и именно интервал фиксирует этот худший случай.
  • Непрерывность Tick через смену авторитета не переносится. Перемещение, которое выполняет платформа, сохраняет состояние Tick участника; замена авторитета этого не обещает. Rooms — место, где объявлены оба, вместе с тем, что происходит, когда льготное окно проходит.
  • Всё, что вы держали только в акторах движка, уходит вместе с процессом. Оно никогда не было объявлено, поэтому за пределами того хоста его не было ни у кого.

Деплой — тот же путь, минус потеря

Слить хост — перестать размещать на нём новые Rooms, дать летящим сессиям доиграть или передаться, а затем отпустить — это путь отказоустойчивости, запущенный намеренно и с предупреждением. Поэтому деплой без убийства живых сессий — не второй механизм, который надо построить и которому надо доверять: это тот же самый, запущенный нарочно, а не крахом.

Хост Room'ы узнаёт об этом так же, как узнаёт что угодно: платформа заранее уведомляет, что Room'у предстоит закрыть или передать по причине на её собственной стороне.

Что происходит, когда окно проходит, объявляется, и значения по умолчанию нет. Тип Room'ы, чей авторитет живёт вне платформы, называет один из трёх исходов его потери: выждать объявленное окно, закрыть Room'у или допустить авторитет-замену. Промолчать Declaration не предлагает, потому что альтернатива — тот самый сбой, ради предотвращения которого оно существует: Room с мёртвым авторитетом, которая всё ещё принимает входы и держит места, показывая каждому участнику живую сессию, в которой ничего не происходит.

Какая машина — не часть вашей поверхности

Вы никогда не называете узел. Тот, кто создаёт Room'у, не выбирает, где она исполняется, и ни одна операция не принимает хост аргументом: размещение принадлежит платформе и остаётся ей, чтобы она могла переместить Room'у, а ваш код не был написан против того, где она была раньше.

Если вы хостите Rooms сами — выделенным сервером или master-client, — верно то же самое с одной добавкой: вам говорят сворачиваться, и доиграть или передать свои сессии внутри льготного окна — ваше дело. Rooms — место, где хост регистрируется для этого биндинга, а Авторитетность — почему хост держит только те права, которые ему выдали.

The live game

Matchmaking

Довести игрока до нужной Room'ы. Тикеты описывают игрока и фильтруют остальных. Матчмейкер разрешает размещение, бронирует место, и дальше игровой трафик идёт прямо в Room'у.

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

Матчмейкер стоит в пути один раз, чтобы решить, где вам место. В пути матча его нет: его исход — это размещение и ограниченная по времени бронь места, а с момента входа игровой трафик идёт прямо в Room'у. Поэтому загруженная очередь никогда не превращается в загруженную игру.

Когда применять

  • Игроков надо маршрутизировать в Rooms по объявленным критериям — режим, регион, ранг, — а не по самодельному списку лобби.
  • Критерии матча обязаны приходить из данных платформы, а не из заявления клиента: штампуйте ранг в Hook перед постановкой в очередь.
  • Очереди должны расширяться со временем на сервере, пока клиент держит один тикет и никогда не опрашивает.
  • Пати обязаны попасть в один матч вместе — Group входит целиком или не входит вовсе.
  • У вас внешний матчмейкер, и нужно только, чтобы его решение завершилось размещением и бронью места.
  • Не нужно, если игроки выбирают сессию сами: браузер Rooms и Join это уже покрывают.

Кто что делает

ActorНа этой странице
playerсоздаёт и отменяет собственный тикет и входит в составе пати
match-organizerобъявляет очереди матчмейкера и их ослабление; читает результаты размещения
backend-serviceштампует доверенные критерии перед постановкой в очередь; исполняет решения внешнего матчмейкера

Одним взглядом

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

Критерии, которым клиенту доверять нельзя, штампуются в Hook перед постановкой в очередь:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

Это чтение — чтение по списку владельцев из Leaderboards, то же самое, которым пользуется когорта друзей, — и каждая возвращаемая строка несёт ранг этого владельца в полной таблице. Hook вправе спросить чужую строку, потому что предикат Leaderboard позволяет это облачной функции; сессия игрока на тот же вопрос получает только свою.

Модель

Что несёт тикет, и две части друг к другу не сводятся.

ЧастьЧто этоКто в это верит
self-descriptionобъявленные свойства участника — рейтинг, режим, язык, выбранная картаникто без проверки: это заявление вызывающего
requirementпредикат, которому обязаны удовлетворять остальныеплатформа, потому что применяет его она

Участник тикета — это Actor или Group: Group входит целиком, и это и есть пати. Её тикет неделим: Group входит в состав целиком или не входит вовсе, потому что разделение Group'ы было бы другим обещанием, а такого нет.

Что объявляет тип очереди.

ОбъявляетЧто это
propertiesпо имени и типу. Свойство, здесь не объявленное, в тикете отклоняется как сбой валидации, а не игнорируется
roster sizeминимум, максимум и шаг совместимости — кратность, при которой состав приемлем, чтобы команды «по пять» означали пять, а не любое число между двумя и десятью
requirement ladderупорядоченный набор предикатов с окнами: каждая ступень — более широкое требование и время, после которого матчмейкинг идёт дальше. Ослабление — это Declaration, а не произвольная логика в обработчике
the predicate languageтот же, которым пользуется всё остальное, и его словарь включает собственные свойства тикета: «рейтинг в пределах ±100 от моего» выразим. Без этого двусторонняя модель не работает вовсе, потому что относительные условия и есть весь её смысл
mutualityприемлем ли состав, в котором A принимает B, а B не принимает A. Значения по умолчанию нет
ticket lifetimeпосле которого тикет переходит в expired с Event
outcomeRoomPlacement — ссылка на Room'у плюс брони в ней, для одновременной игры; или RosterSet — только состав, без Room'ы и без броней, для асинхронной, где соперник офлайн

Состояния тикета. created → queued → matched · cancelled · expired, последние три терминальны.

ВсегдаЧто это
one live ticket per participant per queueвторой — конфликт, а не вторая заявка: прочитайте существующий
the reason for a pairingнаблюдаема: она доезжает до Event матчмейкинга и до истории. Для поставляемых алгоритмов это ступень лестницы; у переопределяющей реализации ступеней может не быть, и тогда причина — непрозрачное значение, которое она объявляет, но она есть всегда
expiryэто исход, а не ошибка: «состав не собрался за объявленное время» — нормальное завершение, доставленное как исход тикета
the outcomeприезжает подпиской, а не опросом. Матчмейкинг занимает секунды и десятки секунд, поэтому опрос превратил бы ожидание в нагрузку, растущую с длиной очереди: клиент держит один тикет и больше не спрашивает
losing the connection cancels the ticketобъявлено, а не выведено: тикет — это заявка играть сейчас, а подбор отсутствующего игрока делает состав хуже для всех остальных
matchedатомарно: для RoomPlacement либо состав подобран и каждый участник держит бронь, либо тикеты остаются в очереди. Для RosterSet атомарный результат — только состав

Ошибки

  • Второй тикет в той же очереди — конфликт; не повторяйте, прочитайте существующий тикет.
  • Необъявленное свойство или требование, называющее такое, — сбой валидации, а не молчаливое игнорирование, которое всплыло бы позже как «соперников не нашлось».
  • Очередь приостановлена отвечает unavailable, а не forbidden: права вызывающего целы, а ситуация временная, поэтому повтор с нарастающей задержкой уместен.
  • Room'у для результата создать не удаётся — тоже unavailable, с нарастающей задержкой.
  • Бронь не удалась — конфликт, который стоит повторить: тикет остаётся в очереди.
  • Тикет, который не найден или чужой, и Actor, которого предикат в очередь не пускает, оба отвечают not found, поэтому отказ не выдаёт ни тикета, ни очереди.
  • «Состав не собрался» никогда не ошибка — см. истечение выше.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Тикетов в очереди — создание отклоняется как конфликт, и существующие тикеты не вытесняются, чтобы освободить место.
  • Время жизни тикета — переход в expired с Event.
  • Ступеней лестницы — Declaration с их избытком отклоняется в момент объявления.
  • Размер Group в тикете — тикет отклоняется как сбой валидации.
  • Объявленных свойств на тип очереди — отклоняется в момент объявления.
  • Частота создания тикетов — отказ по рейт-лимиту со сроком.
  • Удержание истории матчмейкинга — за периодом запись нечитаема по объявленному периоду.

Путь пользователя

От входа до стояния в матчевой Room'е, с рангом, проштампованным на сервере. Путь начинается с Auth & Players, потому что у тикета есть владелец: без сессии ставить в очередь некого.

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Map

Статический мир: границы, ландшафт, препятствия и «куда что может встать?». Физическая модель намеренно гораздо проще визуальной: примитивы с footprint и высотой, слои с правилами и один запрос допустимой позиции, которым пользуется каждый другой модуль.

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Нужен статический мир — границы, ландшафт, препятствия, — который сервер может запрашивать, а не только отрисовывать.
  • Спавны, дроп и декорации обязаны падать в законные места: один запрос RandomPosition по правилам, без обходных путей.
  • Арены должны генерироваться заново на каждый матч — объявленный Seed воспроизводит ту же карту в баг-репорте.
  • Ящики и стены ломаются и возвращаются — разрушаемое с HP и таймерами респавна.
  • Ботам и Projectiles нужны ответы про рейкаст и линию видимости против набора препятствий.
  • Не нужно, если мир чисто визуальный и никакой серверный код не спрашивает, куда что может встать.

Кто что делает

ActorНа этой странице
schema-authorобъявляет карты, примитивы препятствий, разрушаемое, слои и их правила
room-ownerпривязывает карту к Room'е; просит позиции спавна; делает рейкасты
operatorставит или снимает препятствия и слои из панели

Одним взглядом

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter и Destructible — генераторы размещения, а не броски в рантайме. Генератор разрешается, когда версия карты публикуется: сорок камней становятся сорока объявленными примитивами, и опубликованная версия несёт примитивы, а не правило. Поэтому один и тот же Seed даёт те же сорок камней в матче, в реплее и в баг-репорте, а геометрические лимиты проверяются один раз, на этом разрешённом наборе, до того как версия доедет до среды.

Запрос, который задают все остальные:

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

Модель

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

Два слоя, объявляемые разными людьми.

СлойЧто держит и кто его объявляет
staticландшафт с высотой, примитивы препятствий, границы и места — авторский контент
dynamicпрепятствия, принесённые Entities в рантайме: двери, разрушаемое, платформы. Разрушаемое поэтому является Entity с жизненным циклом, состояниями и владельцем, и картой оно становится только в той части, где приносит препятствие: статический камень объявляется в карте, дверь — это Entity, которая его приносит. Собственного жизненного цикла здесь у них нет: он принадлежит Entity

Что объявляет карта.

ОбъявляетЧто это
key и versionкарта — это авторский контент: объявляется в коде, адресуется по key и версионируется, и версия — часть того, на что ссылается Room. Менять геометрию выпущенной версии запрещено; правка — это новая версия
terrainполе высот — регулярная сетка с объявленным шагом, и шаг — объявленный предел точности, поэтому запрос высоты отвечает по нему, а не точно. Ландшафт может отсутствовать: арена в пустоте законна
obstaclesзакрытый набор примитивов — box, sphere, capsule, выпуклая оболочка с объявленным лимитом вершин. Произвольная треугольная сетка не принимается, и это условие того, что серверная проверка вообще возможна
passability kind на препятствиенепроходимо · проходимо для объявленного класса · перекрывает только линию видимости. Один примитив служит и стеной, и кустом, а разница объявляется, а не моделируется дважды
boundsобъём, вне которого позиция недопустима
world strataобъявленные пространственные слои внутри карты — земля, подземелье, воздух. Это Declarations геометрии и адресации
locationsименованные места или области — точка спавна, зона захвата, коридор. Место отвечает на где, никогда на что происходит: игровой логики оно не несёт
placement generatorнеобязательно: правило, порождающее примитивы, — количество, минимальный разнос, область, зерно. Оно разрешается при публикации версии, детерминированно по зерну, и дальше карта держит примитивы, а не правило

Слой мира и экземпляр карты никогда не синонимы.

Что это
world stratumDeclaration внутри карты — земля, подземелье, воздух
map instanceнезависимая копия в рантайме опубликованной карты. Экземпляры делят неизменную опубликованную геометрию и имеют независимые динамические препятствия и независимые составы Entities. Room занимает экземпляр и может выбрать слои внутри него

Что верно про любой запрос.

ВсегдаЧто это
one geometric canonвся геометрия — в объявленном координатном каноне платформы, а точность каждого геометрического поля объявляется на поле
the world modelэто упрощение: геометрия сервера не является художественной моделью и не обязана ею быть
an answer names its instance and its momentна запрос отвечают из статического слоя карты плюс динамических препятствий того экземпляра, о котором спрашивали, и он объявляет момент, на который верен, — динамические препятствия меняются, поэтому ответ это снимок
the valuesуправляемые, а не засеянные: геометрия не является ежедневной настройкой дизайнера — правка из админ-консоли отклоняется, а не сохраняется молча
movement and contactздесь не разрешаются: карта отвечает, что такое пространство, а допустима ли позиция и каков отклик — это Collision, и применение этого к Locomotion

Ошибки

  • Карта, версия или экземпляр, которых нет, отвечают not found, и отозванная версия тоже — повторять бессмысленно.
  • Публикация изменённой геометрии под существующей версией — конфликт: сделайте новую версию.
  • Сбои публикации падают при объявлении, на деплое, а не в рантайме: карта сверх лимита примитивов, выпуклая оболочка сверх лимита вершин и произвольная сетка в качестве препятствия — всё это отказы валидации до того, как что-либо поставится.
  • Запрос высоты вне границ — не ошибка: это объявленный ответ «вне границ», и он отличим от «внутри препятствия», потому что в одном случае клиент разворачивается, а в другом обходит.
  • Превышенная частота запросов отвечает в категории рейт-лимита со сроком.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Примитивов препятствий на карту, вершин выпуклой оболочки, разрешение поля высот, размер границ, мест на карту — каждое из этого отклоняется на публикации, а не в момент запроса: карта, которая поставилась, — это карта, которая уже влезла.
  • Экземпляров карты на карту — создание ещё одного отклоняется как конфликт; существующие экземпляры никогда не освобождаются, чтобы дать место.
  • Хранимых версий — отзывается самая старая устаревающая, и никогда та, под которой живёт Room.
  • Частота запросов к пространству — рейт-лимит со сроком.

Путь пользователя

Эйрдроп по расписанию спрашивает у Map законное место, а игрок подъезжает и забирает. Таблица дропа — пресет Entity, то есть Declaration на Entity, а не модуль, который вы монтируете.

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Collision

Привяжите трансформ к карте препятствий; объявите, что делает контакт. Collision исполняется внутри симуляции платформы. Вы объявляете тела, слои и отклики и подписываетесь на контакты.

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

Когда применять

  • Движущиеся Entities обязаны разрешать контакты на сервере — скользить, останавливаться, отскакивать — без рукописной процедуры отклонения.
  • Геймплей реагирует на касание: подбираемое собирается при перекрытии, объёмы-триггеры запускают машину состояний Entity.
  • Locomotion и Projectiles нуждаются в заметённом разрешении против набора препятствий Map.
  • Предпросмотру размещения или прицеливанию нужны «влезет ли сюда?» и запросы перекрытия объёмов.
  • Не нужно, если ничто физически не встречается: геймплей «запрос/ответ» над записями — это простые Data & Subscriptions.

Кто что делает

ActorНа этой странице
room-ownerобъявляет тела, слои и отклики; запрашивает перекрытия и контакты

К каким Room'ам это относится. Модуль исполняется там, где симуляцию шагает платформа, — в Room'ах, объявленных с Host = "Backend". Если симуляцией владеет ваш собственный game server (PlayServ как метасервер), движение, коллизии и предсказание остаются на стороне движка, а эта страница описывает размещённую на платформе альтернативу, а не требование.

Одним взглядом

Форма, слой и то, что делает контакт, — всё сидит на самом теле; никто не объявляет пары слоёв издалека:

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

Контакт — это Event, и модули на него подписываются. Hook на контакт не существует: к моменту, когда контакт есть, шаг его уже разрешил, и отклонять больше нечего. Там, где студии нужны другие правила, она переопределяет проверки допустимости и пути реализацией — см. Extensibility, — а отклик остаётся объявленным.

Модель

Что объявляет тело.

ОбъявляетЧто это
shapeпримитив из закрытого набора — sphere, capsule, box — с объявленными размерами. Произвольная сетка не предлагается: то же ограничение и та же причина, что у серверной модели мира в Map
where it livesна аспекте, вместе с трансформом: это и есть единица политики, и тело делит с трансформом одну судьбу
how its path is checkedstepwise — проверяется итоговая позиция шага, быстро, и быстрое тело проходит сквозь тонкое препятствие; или swept — проверяется отрезок между позициями, дороже, и туннелирование внутри шага исключено. Объявляется, никогда не выбирается реализацией по скорости: может ли снаряд пролететь сквозь стену — свойство игры, а не оптимизация
areas it participates inвнутри каких объёмов оно считается
its relation to the art modelникакого не требуется: тело — это упрощение, и расхождение с художественной моделью допустимо в объявленных границах

Отклик объявляется на паре — род проходимости препятствия × тип тела — и берётся из закрытого набора:

ОткликЧто означает
stopдвижение прекращается на последней допустимой позиции
slideдвижение продолжается вдоль препятствия той компонентой, которая допустима
bounceнаправление отражается, а скорость умножается на объявленный коэффициент
dampдвижение продолжается со скоростью, умноженной на объявленную долю
passпрепятствие не влияет на движение, но контакт всё равно наблюдаем
cease to existEntity заканчивается — снаряд об стену

Коэффициенты — объявленные значения, а не вычисленные из масс и материалов: ни того ни другого в этом контракте нет.

Что верно про любую проверку.

ВсегдаЧто это
every pairимеет отклик: отсутствующая пара — дефект Declaration, отклоняемый на деплое, а не встречаемый в бою
the response tableчитаема клиентом: та же таблица, по которой считает авторитет, поэтому клиент и сервер с одним Declaration отвечают на один контакт одинаково
reproducible within one authority, not across platformsтот же вход в том же порядке даёт тот же результат внутри одного процесса и одной сборки. Побитово одинаковые результаты на разных платформах и сборках не обещаны, и сетевая модель, построенная на допущении, что коллизии считаются везде одинаково, построена на песке
simultaneityобъявлена: когда два движущихся тела сталкиваются внутри одного шага, порядок разрешения объявлен и детерминирован. Порядок обхода хранилища, порядок прихода входа и случайность основанием для него быть не могут
one contact, one factконтакт двух тел наблюдаем обеими сторонами как единый факт с единым идентификатором, а не как два независимых Events
extension points sit on the step, not on a contactдо шага трансформ можно изменить, после него — наблюдение. Контакт уже случился, значит отклонять нечего; другие правила — это объявленное переопределение проверок допустимости и пути, и такое переопределение обязано быть доступно и клиенту
the moduleсам ничего не двигает: он отвечает, допустима ли позиция и каков отклик, а применение этого принадлежит Locomotion
a contact is an eventпоэтому модули подписываются, а не связываются: машина состояний ловушки привязывает переход к входу в объём-триггер, Drops собирается при перекрытии, а Projectiles разрешают попадания заметанием этого модуля

Ошибки

  • «Недопустимо» — это ответ, а не ошибка, и он называет, которая из трёх причин: вне границ, занято статическим препятствием или занято телом другой Entity. Клиент реагирует на три по-разному — развернуться, обойти или подождать, — поэтому схлопывание их в «нет» стоило бы поведения.
  • Сбои Declaration падают на деплое, а не на первом контакте: тело формы вне закрытого набора, тело на аспекте без трансформа и пара без объявленного отклика — все отклоняются на деплое. Коллизия происходит в бою, и сбой рантайма там наблюдается как исчезнувшая стена.
  • Entity или место не найдены отвечает not found, и повторять бессмысленно.
  • Превышенная частота проверок отвечает в категории рейт-лимита, со сроком, до которого повтор бессмыслен.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Тел в Room'е — объявление ещё одного отклоняется как конфликт; существующие тела никогда не удаляются, чтобы дать место.
  • Контактов на шаг — избыток никогда не отбрасывается молча: либо шаг отклоняется, либо порядок отсечения объявлен.
  • Размер тела против шага сетки карты — отклоняется на деплое, потому что тело меньше шага поля высот проваливается сквозь ландшафт, а это не может быть сюрпризом рантайма.
  • Областей, внутри которых может быть одно тело, — избыток отклоняется на деплое.
  • Частота проверок на Actor — рейт-лимит со сроком.
  • Тел в ответе «кто в этой области» — обрезается по объявленному порядку, и флаг обрезания обязателен.

Путь пользователя

Объём-триггер, машина состояний и дверь: всю проводку делают Events контакта. Плита и дверь — это World Objects, пресеты Entity, а не модули, которые вы монтируете.

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Locomotion

Вы объявляете, как вещь движется; интегратор не пишет никто. Модель движения превращает пронумерованный ввод в авторитетное движение, интегрированное с Collision, записанное для Prediction & Lag Comp и изменяемое баффами, дебаффами и ландшафтом.

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Entities движутся по вводу игрока — танки, персонажи, техника, — и движение обязано быть авторитетным на сервере.
  • Вы предпочтёте объявить скорость, ускорение и лимиты скорости поворота, чем писать интегратор.
  • Геймплей толкает тела: отбрасывание через Impulse, Teleport и модификаторы вроде грязи с длительностями.
  • Движение обязано ощущаться мгновенным: та же объявленная модель шагает на сервере и в цикле Prediction & Lag Comp.
  • Не нужно, если позиции меняются только дискретными шагами: синхронизируемое поле на Entity это уже покрывает.

Кто что делает

ActorНа этой странице
schema-authorобъявляет модели движения, ограничения и привязки
room-ownerприменяет импульс, телепорт и модификаторы с хоста
playerподаёт пронумерованный ввод; читает состояние движения

К каким Room'ам это относится. Модуль исполняется там, где симуляцию шагает платформа, — в Room'ах, объявленных с Host = "Backend". Если симуляцией владеет ваш собственный game server (PlayServ как метасервер), движение, коллизии и предсказание остаются на стороне движка, а эта страница описывает размещённую на платформе альтернативу, а не требование.

Одним взглядом

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

Ввод клиента — это пронумерованное намерение. Движение шагает платформа:

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

Серверные глаголы:

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

Модель

Что объявляет Entity, чтобы двигаться.

ОбъявляетЧто это
movement modelодна из поставляемого набора — steering, tank, character, vehicle, flying — как несколько реализаций одного шага, с объявленными условиями выбора и умолчанием. Шаг — чистая функция (pose, input, dt) → pose
parametersобъявленные значения, которые клиент может прочитать; без них Prediction систематически расходится. Они seed — дизайнер их крутит, и деплой не должен молча терять правки, — тогда как лимиты, на которых стоит анти-чит, могут быть managed, и тогда правка из админ-консоли отклоняется
limitsмаксимальная скорость, ускорение и торможение, максимальный поворот на ввод, множитель заднего хода и отдельная скорость вращения для частей. Поворот на ввод объявлен отдельно от скорости вращения намеренно: один ограничивает мгновенный скачок, другая — непрерывный темп, и это разные защиты
behaviour on stale inputstop, continue until a declared deadline или continue indefinitely. Умолчания «как раньше» нет: игрок, у которого отвалилась сеть, продолжал бы ехать
pose toleranceнасколько далеко заявленная поза может стоять от серверной, и она может различаться по состоянию: стоя, в движении и сразу после респавна — три разных допуска
step rate and catch-up capкак часто исполняется шаг и сколько шагов можно взять разом, когда сервер отстал
impulse kindsкаждый со своей величиной и своим способом затухания

Что верно про любой шаг.

ВсегдаЧто это
the module ownsпозицию и ориентацию Entity во времени, и больше ничего. Историю этих позиций держит Entity, а не он, поэтому у «где был игрок 300 мс назад» ровно один ответ, а не два буфера с разными периодами
authorityсерверная: в режиме our simulation клиент шлёт намерение, никогда результат
collisionsздесь не разрешаются: он спрашивает Collision, допустима ли позиция и каков отклик, и собственной таблицы откликов не держит
inputэто намерение: «вперёд», «вправо», «повернуть башню туда» — принимается как есть, потому что о мире оно ничего не утверждает
a claimed poseэто заявление, а не факт. Вне объявленного допуска она срезается к ближайшей допустимой позе, и это порождает наблюдаемый pose_clamped
input sequencingобязательна: один и тот же порядковый номер никогда не применяется дважды, а меньший отбрасывается
a limitсрезает, а не отказывает: «десять метров вперёд за этот Tick» становится тем, что допустимо, а не ошибкой. Именно это делает лимит анти-читом по построению — сервер физически не может произвести незаконную позу, — и поэтому клиента не заливает отказами каждый кадр
an impulse obeys the same constraintsотдача, толчок, взрыв и отбрасывание приходят помимо ввода, и ни один из них не обходит Collision: отдача не загоняет танк в камень
identical rules, not identical bitsпобитово одинаковый результат между платформами не обещан. Обещаны одни и те же правила и воспроизводимость внутри одного авторитета
the stepчист и управляется Tick: один и тот же код шагает движение на сервере и внутри цикла Prediction на клиенте, и именно это делает сверку точной

Ошибки

  • Отсутствующая модель движения на Entity, заполненный наполовину пресет и импульс без объявленного затухания — всё это сбои валидации на деплое, а не в рантайме: незамкнутый импульс — дефект Declaration, поэтому до игрока он не доезжает.
  • Ожидаемое поколение не совпало — сбой предусловия, который стоит повторить после перечитывания: ввод, отправленный до респавна, не должен применяться после него.
  • Превышенная частота ввода отвечает в категории рейт-лимита со сроком.
  • Entity не управляема — конфликт, и повторять имеет смысл только после смены состояния.
  • Три вещи не являются отказом ни в какую сторону, и все три наблюдаемы. Устаревший ввод отбрасывается, намерение сверх лимита срезается, а поза вне допуска срезается как pose_clamped. Сделать любое из этого молча означало бы оставить клиента в убеждении, что он применил, и навсегда разойтись с сервером.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Максимальная скорость и ускорение — срезаются, никогда не отклоняются.
  • Максимальный поворот на ввод — срезается.
  • Частота ввода на Actor — рейт-лимит со сроком.
  • Догоняющие шаги — сверх потолка шаги отбрасываются с объявленным следствием: время симуляции отстаёт, и это наблюдаемо, а не догоняется скачком, который читается как одновременная телепортация всех.
  • Величина импульса — срезается к объявленному максимуму.
  • Одновременных импульсов на Entity — новый вытесняет самый старый, и вытеснение наблюдаемо; молчаливого неограниченного суммирования нет.
  • Время жизни заявленной позы — старше объявленного периода не рассматривается.

Путь пользователя

Путь одного отбрасывания: player едет, attacker в другом танке стреляет, и импульс приземляется сверенной позой на экране жертвы. Способность и снаряд — пресеты Entity, то есть Declarations на Entities, а не модули, которые вы монтируете.

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Prediction & Lag Comp

Игрок нажал прыжок 50 мс назад. Пакет приехал только сейчас. Он не упал. Предсказание вперёд и компенсация назад над данными, которые несут своё настоящее время события: клиенту мгновенно, сервер остаётся прав, а попадания судятся во временной линии стрелка.

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Ввод обязан ощущаться мгновенным под задержкой, пока сервер остаётся авторитетным, — предсказывать вперёд, сверяться при расхождении.
  • Попадания обязаны судиться во временной линии стрелка: ResolveAt отматывает хитбоксы к сообщённому Tick обзора.
  • Дуги прицеливания и маркеры приземления обязаны совпадать с исходами — клиент и сервер прогнозируют одну и ту же Trajectory.
  • Критичные для игры поля не должны откатываться никогда — объявите, что предсказывается, а что ждёт сервера.
  • Резинку надо крутить: окна на Entity, допуски и телеметрия ошибок предсказания.
  • Не нужно, если задержка не мешает: пошаговые и медленные игры прекрасно живут на обычных Deltas из Data & Subscriptions.

Кто что делает

ActorНа этой странице
schema-authorобъявляет предсказываемые поля против только-авторитетных; задаёт окно предсказания
room-ownerразрешает попадания на историческом состоянии; отматывает мир
playerпредсказывает и сверяет движение; подписывается на поправки

К каким Room'ам это относится. Модуль исполняется там, где симуляцию шагает платформа, — в Room'ах, объявленных с Host = "Backend". Если симуляцией владеет ваш собственный game server (PlayServ как метасервер), движение, коллизии и предсказание остаются на стороне движка, а эта страница описывает размещённую на платформе альтернативу, а не требование.

Одним взглядом

Слово покрывает три разные вещи, сливать их нельзя, и у каждой своя статья. У них разные авторитеты и разные способы ломаться — одно слово на все три означает, что настройка одного молча меняет два других.

МеханизмЧто делаетИсполняется наКогда ошибается
Предсказание собственного движенияприменяет объявленную модель к вашему собственному вводу, не дожидаясь сервераклиентпоправка, переигранная и сглаженная
Показ других игроковрисует чужие Entities между приходящими состояниямиклиентвидимый рывок
Компенсация лагаотматывает цели к моменту, который видел стрелоксерверкто-то умирает несправедливо

Эта страница — узел: общая модель, общие Declarations и пресеты, которые выбирают комбинацию за вас. Три статьи — то место, где каждый механизм действительно объясняется.

Четыре пресета, и «без предсказания» — один из них.

ПресетПредсказывает вашеКомпенсируетСглаживает остальных
shooterдав окне примерно полторы секундыда
arcadeданетда
observerнетнетда
без предсказаниянетнетнет — состояние приезжает от авторитета с объявленным окном интерполяции

Последний — не заглушка. Пошаговым играм, стратегиям и большинству мобильных тайтлов предсказание не нужно вовсе, и объявленное «мы не предсказываем» говорит клиенту показывать состояние как есть, а не догадываться.

Ничего из этого не действует при внешнем авторитете. Все три механизма существуют для Rooms, которые ведёт наша симуляция. Когда Tick'ом владеет игровой сервер студии или master-client, предсказание — дело того, кто его ведёт; см. Кто ведёт Tick.

У модуля нет ни собственной модели движения, ни таблицы откликов, ни геометрии, ни собственного окна истории. Они принадлежат Locomotion, Collision, Map и Entity соответственно. Prediction применяет их раньше или читает назад; второй копии он не объявляет никогда.

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

Разрешение с компенсацией лага отвечает на вопрос «где все были, когда этот выстрел был сделан»:

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Прогноз траектории, общий у сервера и клиента (дуги прицеливания, маркеры приземления). Прогноз — операция этого модуля, экстраполируемая против набора препятствий Map, поэтому обе стороны рисуют одну и ту же дугу из одних входов:

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

Модель

Предсказуемость объявляется на аспекте — и аспект, который меняет только авторитет по правилам, которых у клиента нет, нельзя помечать предсказуемым: это сбой валидации на деплое, а не сюрприз рантайма.

Что объявляется.

ОбъявляетЧто это
predictable aspectsкакие из них клиенту дозволено шагать впереди авторитета
divergence thresholdниже него поправка сглаживается; выше — состояние авторитета принимается как есть. Объявляется и является managed, а не ежедневной крутилкой дизайнера
display mode for remote entitiesинтерполяция между приехавшими состояниями или экстраполяция
interpolation delayнасколько отстаёт показ других, объявляется, а не подбирается на ощупь
extrapolation windowза ним Entity помечается устаревшей, и экстраполяция прекращается
compensation windowкак далеко назад может дотянуться отмотка, и это managed
what is rewoundпозиции и ориентации целей, а также геометрия динамических препятствий, если она объявлена исторической

Что отматывается, а что намеренно нет.

Что это
rewoundпозиции и ориентации целей и геометрия динамических препятствий там, где тип объявляет их историческими
not rewoundсостояние жизни — мёртвые не оживают, чтобы в них выстрелили, — а также владение, счёт и Inventory
the rule behind the splitрешение принимается в прошлом; эффект применяется в настоящем
ВсегдаЧто это
authoritative state names the input it sawоно несёт номер последнего применённого ввода — именно это делает сверку точной, а не приблизительной
divergenceнаблюдаемо: клиент знает, что его предсказание поправили, вместо того чтобы тихо дрейфовать
a view timeэто заявление, а не факт: момент, о котором Actor говорит, что его видел. За окном платформа отказывает, а не экстраполирует: молчаливая экстраполяция — подарок читеру, которому достаточно прислать время постарше
a rewind promises no reproducibility over floating pointто же ограничение, что и везде в контракте
one history ring, two consumersсверка и запросы с компенсацией лага оба читают трек мгновенной истории Entity. Отмотка относится сюда, а не к тем модулям, которые отматываются: кольцо восстанавливает позы спорного Tick, а затем Collision задают обычный для него вопрос о перекрытии этих поз — собственной истории он не держит и ничего не знает про «Tick обзора»
tick клиента:  применить ввод локально (предсказать) → положить в буфер → отправить со штампом tick
tick сервера:  шагнуть ту же модель движения → авторитетное состояние → Delta наружу
приём клиента: авторитетное состояние на tick T → если расхождение вне допуска:
               отмотать к T → переиграть буферизованный ввод T+1..сейчас → сгладить

Откатываются только объявленные предсказываемыми поля; мелкий дрейф сглаживается, настоящее расхождение отматывается и переигрывается.

Ошибки

  • Время обзора за окном — конфликт: пришлите текущее. Экстраполяция вместо этого отдала бы читеру весь механизм.
  • Запрос истории за окном — тоже конфликт.
  • Два дефекта Declaration ловятся на деплое: окно компенсации больше буфера истории и аспект, помеченный предсказуемым, когда у клиента нет правил, чтобы его предсказывать. Ни один не может доехать до живого матча.
  • Две вещи не ошибки, и обе наблюдаемы. Заполненный буфер ввода приостанавливает предсказание до подтверждения, а не отбрасывает ввод молча; а расхождение сверх порога означает, что состояние авторитета принимается как есть, и это объявленная поправка, а не сбой.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Окно компенсации — за ним отказ, никогда не экстраполяция.
  • Окно экстраполяции для чужих — Entity помечается устаревшей, и экстраполяция прекращается.
  • Буфер неподтверждённого ввода — предсказание приостанавливается до подтверждения; ввод никогда не отбрасывается молча.
  • Глубина истории для отмотки — не меньше окна компенсации, и это проверяется на деплое.
  • Частота действий, несущих время обзора, — рейт-лимит со сроком.
  • Одновременно предсказываемых Entities на клиента — сверх потолка предсказание не выполняется, и это объявленная деградация, а не отказ.

Путь пользователя

Выстрел под задержкой, рассуженный честно во временной линии стрелка и подтверждённый на обоих экранах. Снаряд и блок Stats жертвы — пресеты Entity, то есть Declarations на Entities, а не модули, которые вы монтируете.

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

Предсказание собственного движения

Это собственная машина клиента, и из трёх механизмов предсказания только у неё ошибки дёшевы. Вы действуете по собственному вводу до того, как сервер ответил, сервер отвечает, и там, где двое расходятся, ваш клиент поправляет себя. Ошибка здесь стоит небольшой визуальной поправки — ровно поэтому здесь безопасно быть агрессивным.

Предсказание — это повторение объявленных правил, а не вторая копия

Ваш клиент не исполняет параллельную реализацию вашего движения. Он исполняет ту же объявленную модель, которую исполняет платформа, — модель принадлежит Locomotion, а предсказание лишь применяет её раньше. Это и есть вся причина, по которой две стороны согласны большую часть времени: набор правил один, применяется дважды.

А значит, нет ни операции «предсказать», которую надо звать, ни операции «поправить». Предсказание происходит потому, что аспект был объявлен предсказуемым, а не потому, что вы что-то вызвали.

Что предсказывается — объявляется на аспекте

Предсказуемость — это Declaration на аспекте Entity, и это намеренно не глобальный тумблер:

  • Аспект, который клиент может вычислить — позиция под вашим собственным вводом, — предсказывать можно.
  • Аспект, который авторитет меняет по правилам, которых у клиента нет, предсказывать нельзя. Если клиент не может его вывести, догадка порождает откат, который игрок читает как враньё игры.

Эта линия — то место, где вы решаете, чему дозволено мигать, а что обязано быть верным с первого раза.

Протокол поправки и два числа, которые его формируют

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

Авторитетное состояние приезжает, неся номер последнего применённого им ввода, поэтому ваш клиент точно знает, сколько его собственного буфера ещё не подтверждено. Дальше:

  1. Принять авторитетное состояние.
  2. Переиграть буферизованный ввод, пришедший после подтверждённого.
  3. Свести результат с тем, что вы уже показывали.

Два объявленных числа решают, как это ощущается. Порог расхождения: ниже него поправка сглаживается, выше — ваш клиент щёлкает и переигрывает. И граница буфера неподтверждённого ввода: переполнение не является неопределённым — деградация объявлена и наблюдаема, поэтому клиент на плохом соединении знает, что перестал предсказывать, а не дрейфует тихо.

Расхождение наблюдаемо тому клиенту, у которого оно было, и только ему. Вы можете понять, что ваше предсказание поправили и насколько, — это полезно для настройки и для честного индикатора связи игроку. Прочитать чужое расхождение вы не можете: величина ошибки предсказания — информация об их соединении, а не об игре. Поправка живёт на стороне клиента: состояние авторитета — это то, что всем остальным уже показывали.

Если вы приехали откуда-то ещё

  • Mover 2.0 в Unreal. Форма знакома: ввод со штампами Tick, модель движения, поправки от авторитета. Разница в том, где живёт модель: здесь вы её объявляете, а симулирует платформа, — поэтому нашего компонента движения, который можно унаследовать или заменить, для вас не существует.
  • Netcode с откатом и переигрыванием, как в Photon Fusion. Переигрывание собственного неподтверждённого ввода после поправки — тот же механизм, и он здесь целиком. Чего здесь намеренно нет — так это переисполнения мира задним числом; см. Компенсацию лага: что происходит вместо и почему.

Что это не покрывает

Entities других игроков не предсказываются, а показываются — это Показ других игроков. Судить выстрел во временной линии стрелка — серверный механизм, и он живёт в Компенсации лага. И ни один из трёх не действует вообще, когда режим авторитетности Room'ы внешний: тогда Tick принадлежит тому, кто его ведёт, и предсказание тоже.

The live game

Показ других игроков

Других игроков не предсказывает никто — их показывают. Вы получаете их состояние с интервалами и обязаны что-то нарисовать в промежутке. Ошибка здесь не стоит никому жизни; она стоит видимого рывка — поэтому у неё собственные Declarations, а не общие с предсказанием.

Режим показа объявляется, а не угадывается

Для Entities, которые не ваши, Room объявляет, чем заполнять промежуток между приходящими состояниями: интерполировать между теми, что есть, или экстраполировать за самое свежее. Это Declaration на Entity, поэтому ответ одинаков на каждом клиенте и не дрейфует вместе с тем, кто реализовал рендерер.

Задержка интерполяции тоже объявляется. Показывать других плавно означает показывать их слегка с опозданием, на объявленную величину. Назвать число — в этом и смысл: неназванная задержка — это баг-репорт, который вы не воспроизведёте, а названная — дизайнерское решение, которое можно крутить под свой жанр.

Экстраполяция останавливается, а не выдумывает

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

Окно экстраполяции объявлено, и за ним Entity перестаёт показываться движущейся, а не едет дальше по догадке. Экстраполировать бесконечно — значит посадить игрока стрелять по цели, которой там никогда не было, и заметить это он не может; видимая заморозка — та поломка, из которой есть выход.

Почему это отдельно от предсказания собственного

У трёх механизмов предсказания разные авторитеты и разные способы ломаться, и одно слово на все три означает, что настройка одного молча меняет два других.

МеханизмИсполняется наКогда ошибается
предсказание собственногоклиентпоправка, переигранная и сглаженная
показ других игроковклиентвидимый рывок
компенсация лагасерверкто-то умирает несправедливо

Поэтому же существует пресет observer, несущий этот механизм и больше ничего: у наблюдателя нет собственного ввода, который надо предсказывать, поэтому дать ему настройки предсказания означало бы настраивать то, чего он не делает.

The live game

Компенсация лага

Это серверный механизм, и из трёх единственный, чьи ошибки кого-то убивают. Когда он решает неверно, игрок умирает несправедливо — и в пользу того, у кого соединение хуже. Всё на этой странице сформировано этой асимметрией.

Вопрос, на который он отвечает, узок: что стрелок на самом деле видел? Действие может нести время обзора — Tick, на который Actor смотрел, когда действовал, — и платформа восстанавливает позы целей на этот Tick, поэтому выстрел судится против того, что было на его экране.

Время обзора — заявление, а не факт

Оно приезжает от клиента, значит это заявление вызывающего, и обращаются с ним соответственно. Два следствия:

  • Окно компенсации ограничено, и вне его платформа отказывает. Экстраполировать из услужливости она не станет. Отказ — это решение, которое вы видите; молчаливая экстраполяция — решение, которого вы не видите.
  • Чтение прошлого состояния цели всё равно подчиняется видимости. Спросить об историческом Tick — не способ обойти Visibility: чего вы не могли видеть тогда, того не прочитаете сейчас.

А «попадание не засчитано» — это вердикт, а не ошибка: успешный ответ с машиночитаемой причиной. Ваш код задал законный вопрос и получил законное нет.

Что откатывается — объявлено, и это не всё

Откатывать всё звучит непротиворечиво и порождает двойные убийства: двое стреляют друг в друга, обоих отматывают к моменту, когда оба живы, оба попадают. Не откатывать ничего отменяет саму компенсацию лага. Граница между ними — объявленный список, а не интуиция реализации.

Сама отмотка относится сюда, а не к тем модулям, которые отматываются. Кольцо истории восстанавливает позы спорного Tick, а затем Collision задают обычный для него вопрос о перекрытии этих поз — собственной истории Collision не держит, и ничто в нём не знает, что такое Tick обзора. Само кольцо — это трек истории Entity, а не второе хранилище.

Решение принимается по прошлому; эффект применяется в настоящем

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

Компенсация лага отвечает на вопрос о моменте обзора стрелка. Последствия — урон, смерть, начисление — применяются к текущему состоянию. То, что случилось между моментом обзора и моментом решения, не отменяется и не пересчитывается.

Поэтому наблюдаемо и задумано следующее: игрок может успеть выстрелить после того, как был убит чужим отмотанным выстрелом. Отменить это означало бы переигрывать мир поверх отмотки, которая воспроизводимости не обещает, — то есть производить расхождение вместо того, чтобы его убирать.

Пересимуляция на сервере намеренно не входит в объём модуля. Пересчёт последствий против новой правды требует неподвижной точки отсчёта, которой состояние с плавающей точкой нам не даёт. Остаётся всё, на чём модуль стоит: клиент, переигрывающий собственный неподтверждённый ввод (Предсказание собственного движения), и компенсация лага как чтение прошлого ради одного решения. Именно так на практике работает «в пользу стрелка».

Если вы приехали откуда-то ещё

  • Компенсация лага в пользу стрелка, как её поставляет большинство соревновательных шутеров: тот же механизм, и эта страница — он.
  • Полный rollback netcode. Отмотка здесь; переигрывания мира после неё нет, и абзац выше объясняет почему. Если ваш дизайн зависит от того, что последствия пересчитываются задним числом, эту зависимость стоит поднять с нами рано, а не обнаружить поздно.

Что ещё стоит знать

  • Реализацию можно переопределить. Если вашей игре нужно другое правило компенсации, наше можно заменить, и замена объявляет, какие из Declarations она соблюдает.
  • На пути предсказания и поправки точек расширения нет. Они исполняются с частотой Tick, и Hook в этом цикле был бы Hook, который вы не можете себе позволить.
  • Ничего из этого не действует при внешнем авторитете. Компенсация лага существует для Rooms, которые ведёт наша симуляция. Когда Tick принадлежит игровому серверу студии или master-client, компенсация принадлежит тому, кто его ведёт; см. Кто ведёт Tick.
The live game

Bots

Бот входит как обычный игрок. Где-то ещё живут только мозги. Та же сессия, та же валидация входа, те же правила, тот же ACL. Room по построению не может отличить, поэтому боты прогоняют ваши настоящие правила игры, а анти-читу никогда не нужно исключение под бота.

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

Когда применять

  • Ваши лобби надо заполнять в непиковые часы — FillRoom добивает матчи до квоты, и боты уступают места по мере прихода людей.
  • Боты обязаны играть по настоящим правилам — валидация входа, ACL, Visibility, — чтобы анти-читу никогда не понадобилось исключение под бота.
  • Вы приносите внешние мозги — обученную политику, сервис, — которые входят через ConnectAsBot, как любой игрок.
  • Entity отвалившегося игрока обязана передаться боту и обратно при переподключении так, чтобы этого не заметили ни место, ни Prediction & Lag Comp.
  • Не нужно, если персонаж ничего не решает: диалоговый NPC без мозгов живёт в World Objects.

Эта страница — подключающая половина. Как завести бота в Room'у, как заполнить лобби до квоты, как передать место между ботом и человеком. Написание того, что решает, — другая половина: Как написать мозги, где специфицирован разъём, в который мозги втыкаются.

Кто что делает

ActorНа этой странице
bot-brainподключается как игрок; получает восприятие; шлёт команды
room-ownerобъявляет профили, заполняет Rooms до квоты, передаёт бот↔человек

Одним взглядом

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

Внешние мозги (более тяжёлый ИИ, обученная политика, сервис) подключаются как любой игрок:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

Модель

Бот не вводит ни одного собственного понятия — ни участника, ни канала ввода, ни зоны видимости, ни поведения. Это учётные данные Actor, надевшие то, что Rooms, Data & Subscriptions и Locomotion уже объявили.

Что несёт Declaration бота.

ОбъявляетЧто это
thinking tickкак часто спрашивают мозги, и это не Tick симуляции: мозги исполняются снаружи, а сетевой вызов на каждый Tick неосуществим
direction of the brainsгде они исполняются — облачная функция, бэкенд студии, её игровой сервер. Какое именно — не часть контракта, и переезд между ними не ломающее изменение
actor presetправа бота, как обычный пресет Actor
visibility of the bot markerсообщают ли участникам. Сам маркер существует всегда, и платформа всегда его наблюдает; видят ли его игроки — Declaration типа Room'ы, потому что на одних рынках раскрытие ИИ-соперника обязанность, а на других продуктовый выбор
behaviour when the brains are unavailableодно из трёх, без умолчания: do nothing · leave the room · fall back to built-in default behaviour
roster fillingобъявляется типом Room'ы — сколько, при каком условии, до какого момента. Matchmaking о ботах не знает ничего: он подбирает Actors и не решает, кем добивать

Что верно про любого бота.

ВсегдаЧто это
an actor, not a playerон держит учётные данные Actor, но у него нет ни провайдера входа, ни привязок, ни сессий
economic ownershipотсутствует — ни entitlements, ни покупок, ни записей в Leaderboards, — иначе боты оказываются в таблицах и в экономике
perceptionигроцкое: та же зона видимости, тот же предикат, тот же лимит числа объектов. Бот и игрок в одной позиции получают один и тот же набор объектов, поэтому бот не может смотреть сквозь стены больше, чем может игрок
wider perceptionэто пресет Actor, а не свойство бота: отладочный режим или «всеведущий тренер» объявляется пресетом с более широким предикатом
between thoughtsдействует последняя команда, и её судьба — та, которую тип движения уже объявил для устаревшего ввода: бот, чьи мозги думают, — тот же случай, что игрок, у которого отвалилась сеть
room capacityсчитает бота: он занимает место, как любой другой

Ошибки

  • Room не принимает ботов — конфликт, и повтор не поможет.
  • Лимит ботов исчерпан — конфликт, а не forbidden: разрешение ввести бота держат, места в Room'е нет. Стоит повторить, когда Room освободится.
  • Команда боту от Actor без разрешения отвечает forbidden, и повторять бессмысленно.
  • Недоступность мозгов не ошибка: это одно из трёх объявленных поведений выше. Тормозили они, лежали или думали — дело того направления, которое их исполняет, и в контракт это не входит; наблюдаемо то же, что наблюдаемо для любого участника.
  • Объявлено на деплое — отклонено на деплое: бот, названный владельцем записи Leaderboard, и отсутствующий thinking tick — оба сбои валидации на деплое, а не сюрпризы в живой Room'е.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Ботов в Room'е — введение отклоняется как конфликт; существующие боты никогда не удаляются, чтобы дать место.
  • Ботов на проект — тот же конфликт.
  • Thinking tick снизу — Declaration быстрее пола отклоняется на деплое, потому что сетевой вызов на Tick неосуществим.
  • Частота команд одному боту — рейт-лимит со временем.
  • Срок ответа от мозгов — по его истечении действует объявленное поведение при недоступности.

Путь пользователя

Хост добивает лобби до квоты, внешние мозги занимают одно из мест, и Room всё это время исполняется по настоящим правилам.

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

Как написать мозги

Мозги — это обычный код, отвечающий на один вопрос: что бот делает дальше. Они исполняются там, где вы захотите — облачная функция, ваш собственный сервис, безголовый клиент, — и разговаривают с Room'ой через ту же поверхность, которой пользуется клиент живого игрока. Эта страница специфицирует разъём, в который они втыкаются: что мозги получают, что им дозволено слать обратно и когда. Bots покрывает другую половину: как завести бота в Room'у.

Что решено и против чего можно строить уже сегодня

Платформа не поставляет игрового ИИ. Ни деревьев поведения, ни utility-системы, ни навигационных мозгов. Это не дыра, ждущая заполнения, — это граница. Решения ваши, а работа модуля в том, чтобы сделать ваши решения неотличимыми от игроцких.

Мозги — не Hook. Hook оборачивает наш шаг. Мозги не являются нашим шагом вовсе: они исполняются вне Room'ы, по собственному расписанию, и платформе всё равно, с какой стороны было открыто соединение. Поэтому мозги могут быть облачной функцией, сервисом, который хостите вы, или безголовым клиентом, — и поэтому ни один из этих вариантов не «роднее» остальных.

Разъём — восприятие внутрь, команды наружу, и обе стороны намеренно игроцкие:

Что это
восприятиеровно то, что получил бы игрок на этом месте, — те же Deltas, через те же правила Visibility. Бот не может смотреть сквозь стены больше, чем может игрок.
командыровно то, что слал бы игрок на этом месте. Привилегированного канала ввода не существует.

Если игре действительно нужен бот, который видит больше — отладочный режим, тренировочный, — это объявленное расширение, а не побочный эффект того, что он бот.

Thinking tick объявляется, и это не Tick симуляции. Мозги снаружи, поэтому думают в собственном ритме. Между двумя мыслями действует последняя команда — и это самое важное, под что надо проектировать, потому что медленно думающие мозги дают не стоящего на месте бота, а бота, который продолжает делать то, что решил в прошлый раз.

У ухода мозгов объявленное поведение, и умолчания нет. Вы говорите, что происходит, когда мозги перестают отвечать, на тип Room'ы. «Мозги недоступны» — удерживаемый Event, поэтому поздний подписчик узнаёт текущую ситуацию, а не только будущие изменения.

Чем бот намеренно быть не может

Стоит прочитать до того, как проектировать вокруг, потому что это отказы, а не пропуски.

  • Бот не игрок, и он не владеет ни entitlements, ни покупками, ни записями Leaderboard. Бот, который мог бы их держать, был бы способом их изготавливать.
  • Флаг бота существует всегда и всегда наблюдаем платформе. Показывает ли его ваша игра игрокам — ваше решение; существует ли он — нет.
  • Модуль не хранит истории того, что бот решил и почему. Это ваше дело, в вашей телеметрии: мы не собираемся становиться местом, где хранятся рассуждения вашего ИИ.
Services around the game

Auth & Players

Вход — переопределяемый шаг, а не чёрный ящик. Провайдеры, сессии, привязка личностей, баны. Каждая точка потока — до и после входа, до и после привязки, до и после слияния, на смене статуса — объявленная точка расширения с объявленным родом: ворота, которые могут отказать в шаге, или наблюдатель, который не может.

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Игроки обязаны входить — устройство, почта, Apple, Google, Steam или собственный провайдер — с созданием при первом входе как флагом, а не вторым потоком.
  • Гостевой аккаунт обязан позже повышаться — Link добавляет Steam с сохранённым прогрессом, а слияния сводят два аккаунта в одного игрока.
  • Политика обязана исполняться там, где её нельзя пропустить, — региональные ворота перед входом, стартовый набор после входа, который создал игрока.
  • Модерации нужны зубы — отозвать сессии, приостановить, забанить устройство, с Event banned, который слышат все живые системы разом.
  • Объявленный контекст (регион, платформа, сборка) обязан доезжать до каждого следующего Hook так, чтобы каждому не приходилось перечитывать игрока ради этого.
  • Перейти сюда «полегче» не на что: каждый другой модуль называет своего вызывающего через этот, и auth нельзя выключить, пока хоть кому-то из них нужен Actor-игрок: конфигуратор модулей откажет и назовёт зависящих.

Кто что делает

ActorНа этой странице
playerвходит, привязывает или отвязывает личности, обновляет учётные данные, выходит
moderatorотзывает сессии; банит, приостанавливает или восстанавливает игроков
backend-serviceпропускает вход по региону; засевает первые строки нового игрока; читает и отзывает сессии

Одним взглядом

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

Каждая точка настраивается там, где объявлена; формы, которые может принять обработчик, собраны в 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.

Объявленную форму и отрисовывает панель: каждая точка показывает свои обработчики, их род и разрешённый порядок. Род — это часть с зубами:

РодКогда падает сам обработчикПри отказе
вороташаг отклоняется: недостижимая проверка региона — не пройденная проверка регионакод из каталога платформы плюс человеческая причина. Вызывающие ветвятся по коду; текст причины волен меняться и переводиться
наблюдательшаг остаётся сделанным, поэтому стартовый набор, который не доехал, стоит сундука, а не входаотказать он не может

Чего не вправе сделать ни один обработчик — так это решить, кто вошёл. Ворота отвечают да или нет про личность, которую платформа уже установила; они не называют игрока, не выдают личность и не подменяют подтверждение провайдера. Эта линия и есть разница между переопределяемым входом и пропускаемым.

Модель

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

Игрок — носитель личности, а не строка в вашей схеме, и его идентификатор стабилен и никогда не переиспользуется, в том числе при слиянии: идентификатор слитого игрока продолжает разрешаться, а не становится висячей ссылкой. Привязка — это тройка: провайдер · внешний субъект · игрок.

ВсегдаЧто это
provider + subjectуникальна, и эта уникальность — источник конфликта, а не запрета: ответ на «этот аккаунт уже занят» — выбрать слияние, а не услышать нет
at most one link per provider per playerвторой аккаунт того же провайдера — конфликт
an external subjectникогда не идентификатор игрока: он принадлежит провайдеру, и использование его как нашего привязало бы наши идентификаторы к их
identity kind and access statusразные оси: anonymous против registered — одна; active / suspended / banned — другая. Их слияние делает «забаненного анонима» и «приостановленного зарегистрированного» невыразимыми
a session and a credentialразные вещи: сессия — это запись; учётные данные — то, что вы предъявляете. Отзыв сессии обесценивает все её учётные данные и закрывает её открытые подписки
many simultaneous sessionsкаждая отзывается независимо
a credential's claimsэто объявленный контекст — регион, локаль — и только контекст. Claim никогда не несёт авторитета
a device fingerprintне личность: он никогда не основание пустить, только основание отказать, и хранится и сравнивается в необратимой форме

Три машины.

У чегоСостояния
род личностиanonymous → registered, и переход односторонний
статус доступаactive ⇄ suspended, и active → banned → active для разбана
игрокalive → merged, где merged терминально: слитый игрок больше не входит

Что объявляет потребитель.

ОбъявляетЧто это
sign-in policyдозволен ли анонимный вход и остальные правила вокруг входа. Объявляется атрибутом на точке монтирования модуля — не файлом конфигурации рядом с кодом и не конструируется в рантайме
default roleнабор, который несёт новый игрок при первом входе. Умолчания для умолчания нет: не объявите ничего — и новые игроки приезжают без ролей, и это законное Declaration, а не пропуск
session policyчто происходит при достижении потолка одновременных сессий — вытеснить самую старую с Event или отказать новой. Без умолчания
deletion policyкак удаление игрока доезжает до данных, которые на него ссылаются

Где настраивается провайдер. В операторском плане, а не в коде: учётным данным магазина не место в репозитории. То, что объявлено, доезжает до админ-консоли для чтения.

Что делает выдача роли. Роли — не только дело оператора: поверхность несёт grant и revoke для игрока, поэтому игра может повысить офицера гильдии или выдать организатору турнира его полномочия из собственного кода.

ВсегдаЧто это
it is not self-promotionвыдача требует объявленного для неё атома разрешения, и Actor без этого атома получает простое forbidden, а не молчаливый no-op
granting is idempotentвыдать роль, которую игрок уже держит, — успех, а не конфликт: состояние — это набор ролей, а не история вызовов, поэтому в отличие от входа этой операции ключ идемпотентности не нужен
revoking is not instantи мы не притворяемся, что мгновенен. Он вступает в силу без перевыпуска учётных данных и становится наблюдаемым не позже объявленной границы устаревания кэша прав — поэтому код, выдающий роль и тут же проверяющий её на подключённом клиенте, обязан проектироваться вокруг этого окна
the default roleобъявляется на проект: набор, который несёт новый игрок при первом входе. Умолчания для умолчания нет — не объявите ничего, и новые игроки приезжают вообще без ролей, и это законное Declaration, а не пропуск

Из чего сделаны роли и что они открывают — это Access & Roles.

Ошибки

  • Отсутствие учётных данных или истёкшие отвечает not authenticated, и обновление это чинит. Отозванные отвечают так же, но обновление не поможет: только новый вход.
  • Повторно предъявленные проротированные учётные данные — конфликт: именно это делает ротацию обнаружимой, а не молча терпимой.
  • Забаненный или приостановленный игрок и забаненный отпечаток отвечают forbidden, и повторять бессмысленно.
  • Пара провайдер+субъект занята — конфликт, повторяемый после выбора слияния; второй аккаунт того же провайдера — конфликт, которого повтор не изменит.
  • Отвязка последнего способа входа — отказ валидации: она оставила бы аккаунт, до которого никто не доберётся.
  • Слияние уже слитого игрока — конфликт: merged терминально.
  • Недоступность провайдера отвечает unavailable, и её стоит повторить с нарастающей задержкой; отклонение учётных данных провайдером отвечает not authenticated, и стоит одного повтора, а не цикла. Их схлопывание заставило бы клиентов долбить провайдера, который уже сказал нет.
  • Превышенная частота попыток входа отвечает в категории рейт-лимита со сроком.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Одновременных сессий на игрока — по объявленной политике: вытеснение самой старой с Event или отказ новой. Умолчания нет.
  • Попыток входа за период и попыток привязать занятую пару — рейт-лимит со сроком, и счётчик попыток остаётся в истории.
  • Привязок на игрока — привязка ещё одного провайдера отклоняется как конфликт.
  • Время жизни учётных данных — not authenticated, повторяемо обновлением. Время жизни обновляющих учётных данных — только новый вход.
  • Удержание анонимного игрока без входов — удаление по объявленной политике, с Event. Политика объявляется явно; умолчания нет.
  • Записей в списке банов отпечатков — добавление отклоняется, а старые записи никогда не вытесняются молча.

Путь пользователя

Гостевой аккаунт на первом запуске, позже повышенный до Steam с сохранённым прогрессом.

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Profile

Profile — это вид, и платформе принадлежит почти ничего из него. То, что платформа держит об игроке, — это player_id и системный профиль за ним: личности, сессии, привязки провайдеров, всё это в Auth & Players. Всё, чем игрок обладает, — ваша собственная Entity, принадлежащая этому игроку. Profile — это набор таких Entities, который объявляет ваш проект, прочитанный для одного владельца за один проход.

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Экрану нужен срез одного игрока за один вызов — объявленный набор расходится веером по его владеемым Entities вместо того, чтобы клиент сшивал несколько запросов.
  • Поверхности платформы обязаны показывать человека, а не идентификатор: Leaderboard, очередь модерации и тикет поддержки держат player_id и больше ничего, пока проект не назовёт запись, которая отображает игрока.
  • Другому игроку нужна карточка — то же чтение против другого владельца, суженное предикатом строки и маской столбцов, уже объявленными в Access & Roles.
  • HUD обязан следить за владеемым состоянием на живую — чтение это выборка, а выборка подписывается.
  • Не нужно, когда данные не принадлежат игроку: общие и глобальные строки — обычная выборка Entity, и веером расходиться не от кого.

Кто что делает

ActorНа этой странице
schema-authorпомечает Entities как владеемые игроком и объявляет, какие из них образуют Profile
playerчитает собственный Profile; записи идут в сами Entities
room-visitorчитает Profile другого игрока — настолько, насколько позволяют предикат и маска того игрока

Одним взглядом

Членство объявляется на Entity, а не на поле. Entity говорит, что принадлежит Profile; что из неё может увидеть другой игрок — это маска столбцов на роли, которая читает (Access & Roles). Атрибут вида на уровне поля был бы вторым ответом на вопрос, на который доступ уже отвечает, и разъехались бы они в первый же раз, когда кто-то отредактировал один из них.

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

Чтение — это выборка, ограниченная владельцем: поверхность запросов Entity с зафиксированным владельцем и списком Entities, взятым из Declaration. Profile — имя этого чтения, а не модуль, стоящий за ним: те же права, те же предикаты, те же фильтры, та же подписка, потому что это та же операция.

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

Модель

ПонятиеЧто это
player_idвсё представление платформы об игроке плюс системный профиль за ним — Auth & Players
набор Profileвладеемые игроком Entities, которые проект объявляет своим Profile; объявлять его необязательно
выборка по владельцучтение: на вход один владелец, на выход его строки по всему набору — те же права, предикаты, фильтры и подписка, что у любой выборки Entity
публичное чтениета же выборка против другого владельца, суженная предикатом строки и маской столбцов читающей роли (Access & Roles)

Что верно про любое чтение Profile.

ВсегдаЧто это
there is no profile recordу него нет собственного идентификатора, Revision, истории и жизненного цикла, потому что это вид над строками, у которых есть все четыре
writes go where the data livesпоправьте строку progress — и каждое чтение Profile, которое её включает, увидит новое значение на следующем проходе
there is no public writeвиду нечего писать, а разделяемо-записываемое состояние идёт через серверный код
declaring the set is optionalи не объявить его — не то же самое, что объявить пустой: у проекта без набора Profile чтения Profile нет вовсе, и вызов отклоняется как unavailable, тогда как пустой результат сказал бы, что Profile у игрока есть и просто пуст
ownership is a predicateа не столбец, который добавляет платформа (Access & Roles): owner == caller.player — один экземпляр механизма, «один из участников» — другой
a derived field belongs to a hookнаблюдатель после изменения — то, что штампует Progress.Title, когда Level пересекает порог. Он следует за записью; отказать в ней он не может

Ошибки

  • У проекта без объявленного набора Profile чтения Profile нет, и вызов отклоняется как unavailable — а не отвечает пустым результатом, который сказал бы, что Profile у игрока есть и просто пуст.
  • Строка, которую скрывает предикат, отвечает not found, и несуществующая тоже: публичное чтение никогда не становится способом узнать, что существует, но не видно.
  • Поле вне маски читающей роли отсутствует в ответе, а не присутствует пустым.
  • Публичной записи не существует. Виду нечего писать, поэтому разделяемо-записываемое состояние идёт через серверный код, а не через эту поверхность.
  • Запись от имени игрока, не называющая игрока, — отказ валидации.
  • Объявление набора Profile — схемный акт: fn или adm. Ключу игрока, который на это пойдёт, отвечают forbidden, и push отклоняется целиком, а не объявляется наполовину.

Ограничения

Каждый потолок называет своё поведение на границе; числа приедут с главой об ограничениях платформы.

  • Размер страницы выборки по владельцу — режется по потолку, и флаг «есть ещё» остаётся истинным; вернуть меньше без флага запрещено.
  • Размер включённого набора на строку — режется по тому же правилу, с флагом на включении.
  • Частота изменений одного экземпляра — отказ по рейт-лимиту со сроком; чтение Profile — такая же выборка, как любая другая, и наследует потолки Entity, а не объявляет свои.

Путь пользователя

От отрисовки лобби до перещёлкивания уровня: серверная запись доезжает до подписанного экрана без того, чтобы экран спрашивал заново.

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Social

Одно новое понятие, а всё остальное собрано из того, что у вас уже есть. Отношение двух Actors, с собственным состоянием и инициатором, — это всё, что добавляет этот модуль. Клан, гильдия или отряд — это Group со слоем отношений сверху, а не второй род вещей; а блокировка, нужная нескольким модулям, живёт здесь, чтобы владело ею одно место.

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

Когда применять

  • Игрокам нужны друг друга по имени — друзья, подписки, списки блокировок.
  • Клану или гильдии нужна дверь — приглашение от Group'ы, заявка на вступление от Actor и решение по любому из двух.
  • Список друзей обязан показывать, кто в сети, — присутствие выводится из сессий, а кому дозволено его видеть, вы объявляете предикатом.
  • Другому модулю надо знать, что кто-то заблокирован, — он читает это состояние отсюда, а не держит своё.
  • Не нужно, когда вещь — это набор Actors, а не пара с состоянием: это Group, а Group на пару означала бы миллионы Groups по двое, каждая со своим жизненным циклом и правилами входа.

Кто что делает

ActorМожетНе может
playerпредложить отношение или подписаться; принять, отклонить или отозвать; разорвать взаимное; заблокировать и разблокировать; читать свои отношения и присутствие связанных Actors; подписываться на изменения; подать заявку на вступлениечитать чужой список отношений, при каком угодно отношении участников
moderatorрешать по приглашениям и заявкам там, где он держит атом администрирования членстварешать по намерению, разрешения на которое он не держит, — это отвечает forbidden

Модель

Что несёт Declaration отношения.

ОбъявляетЧто это
kindsymmetric — паре нужно согласие обеих сторон, и машина состояний ниже про него; или one-sided — подписка, чьё единственное состояние active. Уникальность на пару и идемпотентность предложения держатся для обоих
re-invitation ruleпосле отказа: запрещено · разрешено спустя объявленный период · разрешено сразу. Объявляется, потому что «спросить ещё раз» — продуктовое решение
presence visibilityпредикат — всем · только взаимно связанным · никому. Значения по умолчанию нет
joining mode (на типе Group'ы)открытый · по заявке с решением · только по приглашению
retention of declined and brokenпосле объявленного периода отношение удаляется, и повторное приглашение снова становится возможным независимо от правила повторного приглашения

Состояния симметричного отношения.

СостояниеСмысл
proposedинициатор предложил, вторая сторона не ответила
mutualобе стороны согласны
declinedвторая сторона отказала. Отношение сохраняется, потому что правилу повторного приглашения надо это знать
brokenодна сторона вышла из взаимного отношения
blockedодна сторона заблокировала другую

Что верно про любое отношение.

ВсегдаЧто это
one entity per pairа не две зеркальные записи. «A предложил B» и «B получил предложение от A» — один факт, прочитанный с двух сторон
an initiatorобъявлен: кто предложил, и это нужно и отображению, и правилу повторного приглашения
blocked dominatesиз него нет перехода в proposed или mutual
a blockасимметрична в управлении и симметрична в действии: снять её может только тот, кто поставил, а действует она в обе стороны
a refusal on a blockеё не выдаёт: операция отвечает not found, поэтому заблокированный Actor не может обнаружить блокировку прощупыванием
the block stateпринадлежит здесь и потребляется в других местах: Messaging и прочие её читают; ни один её не меняет и ни один не держит копии
presenceвыводится из сессий: его никто не пишет, а предикат видимости применяется на каждого спрашивающего, а не единожды на Actor
a deferred intentне занимает места: приглашение или заявка никогда не засчитываются в вместимость Group'ы — иначе сотня заявок исчерпает клан на пятьдесят, и войти не сможет никто
a groupдержит администратора: хотя бы один Actor обязан держать атом администрирования членства, и последний не может просто уйти: клан, чей последний администратор ушёл, больше никогда не смог бы никого принять
no intra-group roles«офицер клана» — это Actor, держащий атом, а не звание, хранимое в списке
an import never overwritesотношения, привезённые от провайдера входа, аддитивны: заблокированный не становится другом оттого, что так сказал провайдер

Ошибки

  • Уже взаимно — конфликт; предлагать нечего.
  • Предложение самому себе — отказ валидации.
  • Одна из сторон заблокировала отвечает not found, а не forbidden, потому что отказ, который их различал бы, выдал бы блокировку. Повторять бессмысленно.
  • Повторное приглашение до срока — конфликт, который стоит повторить после него.
  • Исчерпанный лимит — отношений, намерений — это конфликт, а не forbidden: разрешение держат, места нет. Повторите, когда освободится или когда по существующим намерениям решат.
  • Истёкшее намерение — конфликт: создайте новое, а не повторяйте старое.
  • Уход последнего администратора Group'ы — конфликт, пока разрешение не передано.
  • Решение по чужому намерению без разрешения отвечает forbidden, и повторять бессмысленно.
  • Импорт от неподключённого провайдера отвечает unavailable — повторите с нарастающей задержкой.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Взаимных отношений на Actor — предложение отклоняется как конфликт; существующие никогда не разрываются, чтобы дать место.
  • Односторонних отношений на Actor — новое отклоняется, существующие остаются.
  • Исходящих предложений — новое отклоняется, и вытеснения нет: вытесненное приглашение было бы неотличимо от отклонённого.
  • Блокировок на Actor — добавление отклоняется как конфликт, а старые блокировки не вытесняются: молча разблокированный начинает писать снова, и никто не понимает почему.
  • Время жизни намерения — expired, с Event.
  • Частота предложений на Actor — рейт-лимит со временем.
  • Частота изменений присутствия в потоке — ограничена частотой обновления, а не отбрасыванием изменений.
  • Удержание отклонённых и разорванных отношений — удаление по объявленному периоду.

Путь пользователя

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Messaging

Rooms, Groups, игроки: одна модель адресации для чата и уведомлений. Сообщения приходят в разговор; разговоры — это Channels с историей, модерацией и внеполосной доставкой сверху.

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Игроки разговаривают — чат Room'ы, каналы гильдии, личные сообщения — поверх адресации, которая у вас уже есть: Room, Group, игрок.
  • Офлайновые игроки всё равно обязаны услышать — шаблонные уведомления с расписанием доставляются внеполосно, пушем.
  • Модерация обязана исполняться до доставки — Hook перед отправкой фильтрует или отклоняет, а мьют и блокировка обеспечиваются платформой повсюду.
  • Возвращающимся игрокам нужен догон — History(take: 50) листает разговор при следующем запуске.
  • Не нужно, если payload — это состояние игры, а не разговор: синхронизируемые поля в Data & Subscriptions и Channels Core это уже разносят.

Кто что делает

ActorНа этой странице
playerшлёт и получает сообщения; читает историю; мьютит или блокирует
moderatorфильтрует, редактирует и банит термины
backend-serviceшлёт или планирует шаблонные уведомления

Одним взглядом

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

Структурированное сообщение — это объявленный Event, и дальше разговор несёт его по имени: класс payload в месте вызова конструировать не надо:

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

Объявленное сообщение приезжает типизированным в той же подписке, поэтому клиент, знающий RallyCall, получает поля, а не блоб.

Уведомления внеполосны, шаблонны и планируемы — и шлются из авторитета fn или adm, никогда из сессии игрока:

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

Модерация Hooks, с тем же контрактом, что и везде:

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Модель

Что объявляет тип разговора.

ОбъявляетЧто это
the groupсвой состав — участник это Actor, ровно как в Groups
binding to a lifetimeнеобязательно: время жизни другой Entity, чтобы чат Room'ы исчезал вместе со своей Room'ой

Где проходит линия между конвертом и payload.

ЧастьЧья она
envelopeплатформы: автор, разговор, момент по объявленным часам
payloadстудии, объявляемый типом сообщения с типизированными полями и появляющийся собственной поверхностью отправки, а не нетипизированным мешком

Три вещи, которыми владеет этот модуль и не владеет обычный Event — порядок внутри разговора, период удержания и предмет модерации. Поэтому чат — не «Event с историей»: порядок для игрока, его история и модерация здесь дело платформы, и в Events их нет.

Три машины.

У чегоСостояния
разговорcreated → active → closed
сообщениеsent → published | rejected by the filter, и затем отредактировано или удалено, наблюдаемо
уведомлениеcreated → queued → delivered | expired

Что верно про любое сообщение.

ВсегдаЧто это
order within a conversationустойчив и объявлен. Порядок между разговорами не обещан
editing and deletingнаблюдаемы: сообщение никогда не исчезает молча — иначе история клиента и сервера расходятся, и никто об этом не знает
historyэто сами сообщения: с объявленным периодом удержания, читаемые страницами по курсору с позиции
retention outlives the complaint windowпериод не короче времени, отведённого на разбор жалобы: жалоба приходит после сообщения, а сообщение, которого больше нет, не оставляет предмета разбора
read stateэто позиция, а не флаг: одна позиция на Actor на разговор, и пометка прочитанного монотонна — позиция никогда не уменьшается, поэтому повторный вызов не может отменить прогресс. Счётчик непрочитанного — производная от этой позиции, а не собственный счётчик
sendingидемпотентна по ключу: два вызова — это две реплики диалога, поэтому именно ключ делает повтор безопасным
blockingэто предикат доставки, а не отказ в отправке: отправителю не сообщают, потому что отказ выдал бы блокировку. Само состояние живёт в Social
the sender composes the payloadплатформа не читает данные получателя, чтобы заполнить ваш текст. Локаль получателя может быть объявленным claim'ом контекста, который доезжает до точки расширения, поэтому подстановка и перевод — работа Hook, единственного места, которое знает и получателя, и его локаль
the delivery routeне часть контракта: пуш, внутри приложения или что-то ещё — это решение маршрутизации, а не обещание
deliveryнаблюдаема в объявленных границах: «в очереди» всегда; дальше — настолько, насколько может сообщить маршрут

Каждая точка расширения называет тип, который она отдаёт Hook: исходящее сообщение до публикации, опубликованное — после. Фильтр вправе поправить содержимое, которое ему отдали (маскировка слова — это правка), но никогда не отправителя и не разговор.

Ошибки

  • Разговор, которого нет или который скрыт, и Actor, не являющийся участником, оба отвечают not found — поэтому отказ никогда не выдаёт разговор, в котором вас нет.
  • Закрытый разговор — конфликт.
  • Нет разрешения писать в этом типе отвечает forbidden, и повторять бессмысленно.
  • Отклонение фильтром — вердикт, а не отказ. Вызов был выполнен, содержимое рассмотрено, решение отрицательное, а причина — объявленное значение; поэтому оно отличимо от отказа по разрешениям, и поэтому что делать дальше, зависит от причины.
  • Недоступность фильтра отвечает unavailable и стоит повтора с нарастающей задержкой — но за это время не было опубликовано ничего.
  • Необъявленный тип сообщения для этого разговора и слишком большое сообщение — отказы валидации; содержимое никогда не обрезается молча.
  • Превышенная частота отправки отвечает в категории рейт-лимита со сроком.
  • Правка чужого сообщения отвечает forbidden.
  • Уведомление за своим сроком — конфликт: пришлите новое.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Размер сообщения и вложения с их размером — отправка отклоняется как сбой валидации, никогда не обрезается. Сами файлы принадлежат Files & UGC.
  • Частота отправки на Actor — рейт-лимит со сроком.
  • Глубина истории — за периодом сообщение вытесняется из удержания с Event, а не исчезает тихо.
  • Разговоров на Actor — вход в ещё один отклоняется как конфликт.
  • Уведомлений в очереди на Actor — новое отклоняется, и вытеснение запрещено: молча выброшенное уведомление неотличимо от того, которое никогда не отправляли.
  • Срок уведомления — expired, с Event.
  • Участников в разговоре — лимит Groups, а блокировок на Actor — Social; ни один здесь не повторяется.

Путь пользователя

Одно сообщение о сборе доходит до всей гильдии. Доставку делят две роли: online-member, который в разговоре, когда оно приземляется, и offline-member, который получает пуш и читает сбор из истории при следующем запуске.

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Catalog & Commerce

Предметы, цены, кошельки, витрины, покупки, entitlements. Настоящие интеграции с магазинами там, где платформы это позволяют (Stripe, App Store, Google Play, Steam, Xbox); витрины с расписанием и адресацией по аудитории; и поток покупки, каждый шаг которого можно перехватить Hook'ом.

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Вы что-то продаёте — за реальные деньги через Stripe, App Store, Google Play, Steam или Xbox либо за валюту кошелька.
  • Витрины обязаны вычисляться под каждого игрока — расписание, аудитория и цена считаются на сервере, и никакой математики допуска в клиенте.
  • Правилам ценообразования место в одном тестируемом Hook'е — скидки, переоценка и вето исполняются до любого списания.
  • Чеки обязаны быть защищены от повтора, а возврат обязан отзывать entitlement через те же Events, которыми пользовалась выдача.
  • Не нужно, если вы ничего не продаёте, — хотя награды всё равно приземляются через единственный Grant коммерции с происхождением reward (сундуки за цикл Leaderboards приезжают именно так), поэтому даже игра без магазина сохраняет единый аудируемый журнал выдач.

Кто что делает

ActorНа этой странице
playerлистает витрины, покупает, управляет кошельком, активирует коды
sellerнастраивает каталог, цены и расписания витрин
backend-serviceпроверяет чеки; переоценивает или выдаёт через Hooks покупки

Одним взглядом

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

Модель

Что объявляет предмет каталога.

ОбъявляетЧто это
keyэто авторский контент, адресуемый ключом, поэтому переименование в коде — это переименование
kindconsumable — тратится; или durable — приобретается один раз
pricesцена — это денежная величина: целое число в младших единицах плюс код валюты, никогда не float. Предмет может нести несколько — и игровую валюту, и реальную
external identifier per providerодин слот на провайдера, объявляемый, потому что магазин знает предмет по собственному идентификатору
what it points atнеобязательно — Entity любого объявленного рода, и тогда покупка предмета выдаёт владение этой Entity

Чем дозволено быть композиции.

Что это
what is purchasableпредмет каталога, никогда произвольная Entity: цена, которую никто не обслуживает, — не обещание, потому что покупке нужен тот, кто выдаёт право и отвечает за возврат
two levels, no thirdбандл — это предмет каталога, составленный из предметов; витрина — это набор офферов, а оффер указывает на предмет и может перебить его цену и состав бандла
a territorial priceвыражается витриной, а не на предмете

Что объявляет витрина.

ОбъявляетЧто это
offersнабор, каждый указывает на предмет
scheduleв реальном времени, всегда UTC: когда окно открывается и закрывается
audienceпредикат, а не список игроков, — поэтому аудитория это правило, которое продолжает быть истинным, а не снимок

Витрина, в аудиторию которой игрок не попадает, для этого игрока не существует.

Состояния заказа.

ИзВ
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid или grantedrefunded
ВсегдаЧто это
the priceфиксируется в заказе в момент его создания, поэтому изменение цены после не может поменять то, о чём договорились
awaiting paymentимеет объявленный срок, объявляемый на провайдера, потому что они разные
grantingотделена от оплаты: paid и granted — разные состояния: приход денег и появление вещи — два факта, и их слияние прячет, который из двух не удался
a refundвнешний переход: он приходит без всякого запроса с нашей стороны, в любой момент, и что происходит с выданным, объявляется — ответов три, умолчания нет
an entitlementнесёт своё происхождение — покупка, промокод, награда, подарок, — поэтому «откуда это взялось» отвечаемо и через год
a consumable entitlementнакапливается: он меняется инкрементом с ключом идемпотентности, никогда перезаписью прочитанного
ownershipэто предикат владельца: entitlement принадлежит игроку тем же механизмом, что любая владеемая строка
the catalogобъявляется в коде и доезжает до панели в режиме владения seed по умолчанию: код создаёт отсутствующее, а правки дизайнера переживают следующий push
provider secretsживут в операторском плане, никогда в Declaration и никогда в репозитории
a provider's capabilitiesобъявлены: есть ли у него пригодный API вообще и что он умеет, — чтобы каталог не обещал поток, который магазин обслужить не может

Каждая точка расширения называет тип, который она отдаёт Hook'у: намерение купить — до покупки (игрок, оффер, провайдер, цена) и сама покупка — после. Hook никогда не получает нетипизированный мешок.

Ошибки

  • Вне аудитории отвечает not found, и повторять бессмысленно. Вне расписания тоже отвечает not found, но стоит повтора, когда окно откроется.
  • Провайдер недоступен и провайдер отклонил платёж — намеренно разные ответы: первое unavailable и повторяемо с нарастающей задержкой, второе конфликт, который повтор не починит. Их слияние заставило бы вызывающих вечно повторять отказ.
  • Недействительный чек — отказ валидации; чек, уже потреблённый другим заказом или другим игроком, — конфликт: именно это делает повтор бесполезным.
  • Цена изменилась между чтением витрины и покупкой — сбой предусловия: перечитайте и решите заново, а не платите новую цену молча.
  • Недостаточно игровой валюты — конфликт, а не forbidden: разрешение покупать держат, баланса нет. Стоит повторить после пополнения.
  • Уже имеющийся durable-entitlement — конфликт.
  • Регион или возраст не позволяют покупку отвечает forbidden, и повторять бессмысленно.
  • Срок заказа прошёл — конфликт: создайте новый заказ.
  • Исчерпанный лимит трат отвечает конфликтом или рейт-лимитом — смотря какой это был лимит, — и называет, когда лимит сбрасывается.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Размер каталога — публикация ещё одного предмета отклоняется как конфликт.
  • Витрин на проект — создание отклоняется.
  • Офферов в витрине — добавление отклоняется; витрина никогда не обрезается молча.
  • Время жизни заказа в ожидании оплаты — переход в expired, с Event.
  • Частота попыток покупки — рейт-лимит со сроком.
  • Лимит трат за период — конфликт, который называет, когда лимит сбрасывается.
  • Удержание заказов — за периодом заказ становится нечитаемым по объявленному периоду, а не исчезает без объяснений.
  • Entitlements на игрока — выдача отклоняется, а уже выданные никогда не вытесняются.
  • Точность цены — не лимит, а тип — целое в младших единицах.

Путь пользователя

Первая покупка нового игрока: витрина собирается, цена уменьшается вдвое, предмет приземляется — и продажа доезжает до воронки первой покупки, которую operator читает в Analytics.

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Inventory

Здесь всё сходится. Выстрелы списывают патроны, дроп сюда приземляется, способности сюда заглядывают, движение этим изменяется — один владеемый набор строк, со стеками, которые инкрементируются, и потолком на владельца, чьё поведение на границе выбираете вы.

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

Когда применять

  • Игроки чем-то владеют, и владение — это строка с владельцем: читается по владельцу, ограничена на владельца, с объявленным, а не подразумеваемым поведением при переполнении.
  • Количество накапливается — стек меняется инкрементом с ключом идемпотентности, поэтому повторённое списание не списывает дважды.
  • Другие модули тратят из одного набора — выстрелы списывают патроны, дроп выдаёт лут, покупки появляются строками против своего entitlement.
  • Не нужно, если число не владеемо: hp, xp и откаты принадлежат Stats.

Кто что делает

ActorНа этой странице
playerчитает собственные владения и тратит из них
backend-serviceвыдаёт, инкрементирует и отзывает от имени игрока, называя игрока, за которого действует

Одним взглядом

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

Сессия игрока делает чтения, перемещения и проверку «хватает ли» теми же вызовами. Выдача, потребление и уничтожение — не её дело: платформа отклоняет их как forbidden и называет право, которого вызывающему не хватает, каким бы биндингом вызов ни был сделан.

Модель

Inventory не вводит ни одного собственного понятия. Это пресет — форма, собранная из того, что Entity уже даёт, поэтому всё ниже является Declaration Entity, а не механизмом этой страницы. Пресет, которому понадобился бы новый род Declaration, был бы дырой в контракте, а не поводом пресет расширить.

ОбъявляетЧто это
владеемый типвладение принадлежит владельцу, а выборка по владельцу — собственная операция Entity
ref на предмет каталогассылка хранит id предмета и никогда его key — именно это делает переименование ключа безопасным. Само определение живёт в Catalog & Commerce
аспект стека с инкрементомстек меняется дельтой, а не перезаписью прочитанного. Инкремент не идемпотентен по природе — два инкремента это два инкремента, — поэтому он обязан принимать ключ идемпотентности, а способ установить исход — адресованное чтение
потолок на владельца с поведением на границеодно из трёх, и умолчания нет: refuse · redirect в объявленную корзину владельца · discard with event

Что верно про любое владение.

ВсегдаЧто это
the cap has no defaultтри ответа на полную сумку — это три разные игры: отказ теряет добычу на глазах у игрока, перенаправление — это почта или переполняющийся склад, отбрасывание — тихая потеря, законная лишь потому, что она объявлена и наблюдаема. Ни один не верен для всех трёх, поэтому выбирает Declaration
the owner is immutableничто не переходит из рук в руки правкой поля: владение перемещается отзывом плюс новой выдачей с объявленным происхождением, и оба факта остаются в записи. Правка владельца вместо этого стёрла бы след, оставив «откуда это у меня» и «это у меня забрали» без ничего за текущим состоянием
a transfer between two playersэто другое обещание: ему нужны эскроу и антифрод, и в эту версию он не входит
a rowотображает entitlement, а не является вторым его источником: купленное живёт в Catalog & Commerce, а строка здесь его представляет

Ошибки

  • Экземпляра не существует или его скрывает предикат — ответ not found в обоих случаях, поэтому отказ никогда не выдаёт, что нечто существует, но не ваше.
  • Поле, не объявленное в аспекте (включая вложенные), и обязательное поле без значения — отказы валидации, называющие поле.
  • Версия не совпала — сбой предусловия, который стоит повторить после перечитывания.
  • Запись от имени игрока, не называющая игрока, — отказ валидации, а не молчаливая запись от чужого имени.
  • Отсутствие разрешения на чтение или запись отвечает forbidden, и чтение с записью различаются.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Экземпляров на владельца — по объявленному правилу выше, и умолчания нет.
  • Размер хранимого экземпляра — запись отклоняется как конфликт, и отказ называет провинившееся поле и измеренный размер. Потолок достигается накоплением, поэтому приближение к нему наблюдаемо до падающей записи.
  • Частота изменений одного экземпляра — отказ по рейт-лимиту со сроком.
  • Размер страницы выборки — страница режется по потолку, и флаг «есть ещё» остаётся истинным; вернуть меньше без флага запрещено.

Путь пользователя

Патроны одного выстрела, от каста, который их списывает, до дропа ящика, который их возвращает. Способность, снаряд, блок Stats ящика и таблица дропа в нём — пресеты Entity, то есть Declarations на Entities, а не собственные модули.

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Leaderboards

Каждая механика, систематизированная. Не каталог типов таблиц. Одна модель, чьи оси складываются во все они: дневные таблицы, борды лучшего круга, суммы гильдий, сезоны, турниры.

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

Этот блок читается так: кто действует на этой странице (actors), что модуль вам даёт (provides), на каких модулях он стоит (builds-on) и где он висит относительно корня: mounts: root означает playserv.Leaderboards, а не пространство имён под другим модулем (как монтируются модули).

Когда применять

  • Счёт обязан ранжировать игроков — дневные таблицы, борды лучшего круга, суммы Groups — одной объявленной моделью, а не системой на каждую таблицу.
  • Нужны стандартные чтения — топ-N, вокруг меня, именованный список владельцев — без дополнительного моделирования данных.
  • Циклы обязаны закрываться по расписанию, архивироваться (никогда не удаляться) и запускать Hook награды с итоговой таблицей.
  • Подозрительные результаты не должны попадать в таблицу — Hook перед отправкой проверяет, срезает или отклоняет с типизированной причиной.
  • Турнир — тот же борд с окном заявок, лимитом участников и попытками за цикл.
  • Не нужно, если число никогда не сравнивается между игроками: личный счётчик или карьерная сумма — обычные Data & Subscriptions. Модуль упорядочивает результаты; он их никогда не вычисляет и не ведёт турнирную сетку на выбывание.

Кто что делает

ActorНа этой странице
playerчитает топ-N / вокруг-меня / собственный ранг, подписывается на изменения ранга
backend-serviceотправляет результаты; поправляет или отклоняет их в Hook перед отправкой; выдаёт награды при закрытии цикла
operatorобъявляет борды; закрывает цикл досрочно, поправляет записи (с аудитом), следит за частотой отправок

Одним взглядом

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

Declaration живёт рядом с остальной вашей схемой — в серверном проекте, в проекте UE или Unity, — и playserv push его компилирует и отправляет наверх: борд появляется в панели, пустым, с уже запланированным следующим сбросом. Расписания в UTC, поэтому этот борд закрывается в понедельник 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") двигает час. Локальное время каждого игрока вариантом сброса не является: одна таблица не может закрыться в двадцать четыре разных момента.

Ключ порядка — это список, а не счёт плюс тай-брейк. Поля ранжируют в том порядке, в котором вы их пронумеровали, каждое со своим направлением, а последний ярус принадлежит платформе: при равных ключах выше стоит более ранняя отправка, поэтому два одинаковых забега никогда не меняются местами между двумя чтениями. Поле вне ключа — здесь Map — несётся для отображения и строку не двигает никогда.

Agg говорит, что вторая отправка делает с единственной записью владельца в текущем цикле:

AggЧто делает вторая отправкаИдемпотентно
Setзаменяет запись отправленными значениямида
Bestзаменяет её только тогда, когда новые значения выше по ключу порядкада
Incrementприбавляет отправленные значения к записи — фраги, круги, вклад в гильдиюнет — несите ключ идемпотентности
Decrementвычитает ихнет — несите ключ идемпотентности

Отправка, которая не побила запись Best, — не ошибка: она возвращается принятой, порядок не изменился. Increment и Decrement — те два, которые повторённый вызов применил бы дважды, поэтому они принимают тот же ключ идемпотентности, что любая другая повторяемая запись.

Отправка — один вызов, и на этом борде она приходит из серверного кода, потому что так сказало Declaration:

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

Три вещи в этом вызове стоит прочитать по отдельности:

В вызовеЧто это
PlayServ · playservhandle облачной функции и экземпляр клиента, который SDK выдаёт вам на старте. Один и тот же API, два вызывающих — в Go это ps и psv, и каждый сниппет пользуется тем, что есть у его вызывающего
отправительфункция, которой принадлежит результат матча. В Tanks это Hook on dispose у Room'ы (Rooms), исполняющийся с финальным состоянием на руках
playerIdплатформенный id игрока из Auth & Players, а не имя, которое выбрали вы: Hook читает его из своего payload (e.By.PlayerId в уроке), а хост Room'ы отправляет id того места, которым владеет

Значения — это поля, названные Declaration: необъявленное поле отклоняется, а не сохраняется.

Чтения, нужные каждой игре, и подписка, которая держит их актуальными:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) — это окно по рангу, а не страница: пять строк выше вас, пять ниже и своя — одиннадцать строк, симметрично подрезаемых там, где таблица кончается, поэтому ранг 2 получает более короткое окно с обеих сторон, а не сдвинутое. Top листается: он возвращает первые N строк и курсор, а after: идёт по остальным.

ForOwners — то, как работает борд друзей. Платформа не держит графа друзей; вы передаёте владельцев, которые у вашей игры уже есть, — участников Group или список идентификаторов из ваших собственных данных, — и каждая строка возвращается со своим рангом в полной таблице, а не с рангом внутри списка.

OnRankChanged доставляет собственный ранг локального игрока и больше ничего: борд на пятьдесят тысяч участников не проталкивает каждую перетасовку каждому клиенту. Колбэк получает изменившуюся строку — ранг, ранжирующие поля, отображаемые поля, — а Cancel() заканчивает подписку. Сам ранг — это снимок: два чтения с интервалом в секунду могут различаться, пока приземляются отправки, хотя ваша собственная отправка всегда видна вашему же следующему чтению.

Модель

Что объявляет борд.

ОсьЗначенияКак задаётся
Владелецигрок · GroupOwner = Owner.Player — борд гильдии это тот же борд с Owner.Group
Ключ порядкаодно или несколько объявленных полей, каждое по возрастанию или убыванию[Rank(1, Sort.Descending)] int Score
Агрегацияset · best · increment · decrementAgg = Aggregation.Best
Сбросрасписание в UTC; цикл истекает, а не удаляетсяReset = Reset.Weekly(DayOfWeek.Monday)
Кому дозволено отправлятьтолько сервер (по умолчанию) · игрокиSubmit = Submit.ServerOnly
Отображаемые поляобъявляются и типизируются; частью порядка не являются никогда[Display] string Map
Список владельцеввыбирается в момент чтения, не объявляетсяForOwners("weekly-score", ids) — друзья, гильдия, лобби
Правила турнираокно заявок · лимит участников · попытки за цикл · требуется вступлениеRules = Tournament.Define(…), в таблице под Tournaments

Оси области нет: борд на регион, на Room'у или на сезон — это борд на ключ, а ключ — то, на что ссылается ваш код.

Что верно про любой борд.

ВсегдаЧто это
direction and operatorнеизменны после первой записи: их смена молча переранжировала бы историю; способ поменять механику — новое поколение, а не правка
exactly one entry per owner per generationвторая — не вторая строка
an entryне Entity: собственного жизненного цикла нет, машины нет; она создаётся первой отправкой и меняется тем оператором, который объявил борд
fields outside the order key never affect the orderони отображение, и поэтому объявляются отдельно
a generationистекает, а не удаляется: open → expired → evicted from retention, и истёкшие поколения остаются читаемыми объявленный период удержания
the schedule transitionнаблюдаем Event, поэтому обработчик читает ровно ту таблицу, которая закрылась, а не пустую, которая только что открылась
the default submitterсервер: кому дозволено отправлять — объявляется, и по умолчанию это не игрок
a boardавторский контент: объявляется в коде, адресуется по key, доезжает до админ-консоли в режиме владения seed, поэтому правки расписания дизайнером переживают следующий push
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

Что такое цикл и что делает его закрытие.

Что это
a resetзакрывает цикл, а не удаляет его
a closed cycleперестаёт принимать отправки и остаётся читаемым по своей метке — Top("weekly-score", 100, cycle: label), параметр чтения, а не задача выгрузки
the close eventнесёт эту метку, поэтому обработчик читает ровно ту таблицу, которая закрылась, а не пустую, которая только что открылась

Два Hooks на борде, и род у них разный.

HookЧто ему дозволено
pre-submitgatekeeper: платформа зовёт его и ждёт. Он может поправить отправленные значения против ваших собственных Entities, срезать их или отклонить с типизированной причиной, а если он падает, отправка отклоняется — fail-closed. Он не вправе поменять владельца записи или её борд: это уже заявлено. Он возвращает вердикт — принять, принять исправленную отправку или отклонить, — и отклонение доезжает до вызывающего типизированной проблемой (Core), той же формы, что любой отказ в SDK
cycle-closedobserver: срабатывает по факту, вето наложить не может, и сбой там оставляет цикл закрытым
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

Награда — это выдача Catalog & Commerce, а не механика этого модуля: chest.gold — идентификатор каталога, а происхождение reward — то, что отделяет выдачу от покупки: возвраты, отзыв и Event изменения entitlement работают на ней ровно так же, как на купленном предмете.

Турниры. Турнир — это тот же борд плюс ограничения участия; второго механизма и отдельной Entity нет. Четыре ограничения, с их единицами и поведением на границе:

ОграничениеОбъявляется какНа границе
окно заявокentryWindow: TimeSpan — как долго вступление остаётся открытым после открытия циклавступление после его закрытия отклоняется; цикл всё равно идёт до своего сброса
лимит участниковmaxEntrants: int — записей в одном циклеучастник 65-й из 64 отклоняется как конфликт, и ничто не вытесняется: борд, сбрасывающий худшие строки, ранжировал бы того, кто пришёл первым
попытки за циклattemptsPerCycle: int — отправок на владельцаследующая отправка отвечает «попытки исчерпаны» — конфликт, а не ошибка разрешений, и счётчик сбрасывается вместе с циклом
требуется вступлениеjoinRequired: true — участники это членство, а не все, кто играетотправка от неучастника отклоняется

Ежедневный турнир объявляет все четыре от начала до конца.

Ошибки

Сессия player, вызывающая операцию, которую этот борд оставляет за fn, — отправку в борд только для сервера, досрочное закрытие цикла — отклоняется как ошибка разрешений до того, как что-либо записано; тот же вызов из облачной функции проходит. Отправка в цикл, который уже закрылся, — это вместо этого конфликт: право есть, цикла нет, а повтор — это отправка в текущий.

Ограничения

Каждый лимит вместе с тем, что происходит на его границе.

ЛимитНа границеЧисло
строк на чтениестраница режется, «есть ещё» остаётся истинным, after: продолжаетпотолок страницы задаётся на проект
окно вокруг владельцарежется симметричнопотолок окна задаётся на проект
записей в одном циклеотправка отклоняется как конфликт; вытеснения нетmaxEntrants на борд; без ограничения, если не задан
попыток на владельца за циклконфликт «попытки исчерпаны», снимается сбросомattemptsPerCycle на борд; без ограничения, если не задан
частота отправок на владельцаотказ по рейт-лимиту, несущий момент, когда повтор разрешёнчастота задаётся на проект
бордов на проектновое Declaration отклоняется на деплоелимит задаётся на проект
удержание закрытых цикловцикл покидает хранилище с Event; чтения затем отвечают not-foundокно удержания задаётся на проект

Путь пользователя

Одна неделя борда weekly-score: серверные отправки, чтение вокруг-меня, закрытие в понедельник и его награды.

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Files & UGC

Файлы приходят порциями и обрабатываются по мере прихода. Загрузки, ассеты и их производные варианты, а также контент игроков с путём модерации.

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

Когда применять

  • Игроки или сервисы загружают блобы — порционные возобновляемые сессии с читаемыми квотами на игрока.
  • Обработка обязана начаться до окончания загрузки — читайте файл потоком, порция за порцией.
  • Контенту, сделанному игроками, нужен путь модерации — SubmitUgc, очередь, вердикт, Hooks с обеих сторон.
  • Одно мастер-изображение обязано обслуживать много платформ — выводите варианты (масштаб, перекодирование) и держите оригинал каноническим.
  • Не нужно для маленьких структурированных payload'ов: поле записи Data & Subscriptions их несёт без сессии загрузки.

Кто что делает

ActorНа этой странице
playerзагружает порции, читает файлы потоком, отправляет UGC
moderatorпросматривает очередь, одобряет или отклоняет отправки
backend-serviceвыводит варианты ассетов; вешает Hooks на загрузку и модерацию; задаёт квоты

Одним взглядом

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC, путь игрока:

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

Ворота вокруг него — Hooks, с тем же контрактом, что и везде:

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Оба Hooks оборачивают шаг операции, никогда не Event: Before(Files.Upload) решает, начнётся ли загрузка, а After(Files.SubmitUgc) исполняется, когда отправка уже существует, и кладёт её в очередь модерации через собственный Moderation.Enqueue модуля. Events — загрузка завершена, вариант готов, UGC отправлен, вердикт модерации — уходят подписчикам, а подписчик не накладывает вето ни на что.

Модель

Файл — это непрозрачные байты плюс объявленные метаданные (происхождение, тип содержимого, размер), и это не хранилище состояния, по которому принимаются решения. Его связь с моделью игры идёт в другую сторону: поле на вашем типе держит ссылку; файл об игре не знает.

Что объявляет род файла.

ОбъявляетЧто это
originавторский контент, порождённый игрой или порождённый пользователем, — и лимиты размера и политики следуют из этого
admissible content typesобъявленным списком, никогда не вынюхиваемым из байтов
size limitsпроверяются при открытии сессии, по объявленному размеру, а не на последней части
part size and orderзагрузка выполняется сессией: объявленный размер части, порядок частей, точка возобновления
derivativesнеобязательно: именованные варианты, порождаемые обработчиком, — и готовность каждого объявленного варианта наблюдаема, поэтому клиент никогда не гадает, существует ли уже миниатюра
storage prefixна поле схемы, несущем ссылку: где лежат байты, и больше ничего. Не каталог: ни переименования, ни перемещения, ни разрешений на префикс, ни рекурсивных операций. Файл по-прежнему адресуется своим id или key, и префикс в этом не участвует

Что верно про любой файл.

ВсегдаЧто это
completionидемпотентно по сессии: повторное завершение возвращает тот же файл, а не второй
a checksumобязательна, и несовпадение — отказ, а не молчаливое принятие испорченных байтов
a published fileнеизменен: правка — это новая версия, а ссылка на версию продолжает указывать на то, на что указывала
authored contentадресуется ключом плюс версией и является managed: в админ-консоли не правится, потому что им владеет код
an unfinished sessionумирает наблюдаемо: за своим сроком она завершается с Event, а её части освобождаются
ownershipследует предикату владельца: файлы, порождённые пользователем и игрой, имеют владельца, как любая владеемая строка, и файлы владельца подчиняются политике удаления игрока — каскад, отказ или анонимизация, объявляемые, а не подразумеваемые
read accessможет зависеть от entitlement: платный ассет закрывается entitlement'ом Catalog & Commerce, а не второй системой разрешений

Каждая точка расширения называет тип, который отдаёт Hook, — намерение загрузить до загрузки, отправку после, — поэтому Hook никогда не получает нетипизированный мешок.

Ошибки

  • Отсутствие entitlement отвечает not found, а не forbidden — иначе список отказов выдаёт, какие дополнения существуют. Снятый файл отвечает так же.
  • Истёкшая сессия — конфликт: откройте новую.
  • Часть вне объявленного порядка или размера, необъявленный тип содержимого и размер сверх лимита — отказы валидации, причём размерный приземляется при открытии сессии, а не после того, как байты уже проехали.
  • Несовпадение контрольной суммы — отказ валидации, который стоит повторить: перешлите часть.
  • Исчерпанная квота — конфликт, повторяемый после освобождения места.
  • Истёкшая выдача на чтение отвечает not authenticated — запросите новую выдачу, а не считайте это проблемой разрешений.
  • Отклонение при разборе — вердикт, а не отказ: отправку рассмотрели, и ответ отрицательный с объявленной причиной, поэтому что делать дальше, зависит от причины.
  • Превышенная частота загрузок отвечает в категории рейт-лимита со сроком.

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Размер файла по происхождению — загрузка отклоняется до приёма любой части, а не на последней.
  • Размер части — часть отклоняется как сбой валидации.
  • Время жизни сессии — expired с Event, и части освобождаются.
  • Квота хранилища на проект и на игрока — новая сессия отклоняется как конфликт, и уже опубликованное никогда не удаляется молча, чтобы дать место.
  • Хранимых версий авторского контента — снимается самая старая, и та, на которую ссылается действующая среда, никогда.
  • Частота загрузок на Actor — рейт-лимит со сроком.
  • Удержание контента, порождённого игрой, — за периодом снятие с Event.

Путь пользователя

Один уровень, построенный игроком, от первой загруженной порции до вердикта об одобрении.

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Analytics

Всё, что нужно посчитать потом, а не увидеть сейчас. Объявите типизированное событие телеметрии, испустите его — и оно приземлится рядом с собственными событиями платформы: пройденный уровень, шаг воронки, экономическое событие, длина сессии, отвал в туториале. Этот модуль испускает; он не читает, не агрегирует и сам никуда ничего не отправляет — направление, в котором едет батч, принадлежит роутеру, в Extensibility.

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

Когда применять

  • Нечто обязано быть посчитано потом — шаг воронки, пройденный уровень, экономическое событие, длина сессии.
  • Сравнение обязано пережить сборки игры — тип несёт версию схемы, поэтому годовалая воронка не оказывается молча склейкой двух разных смыслов одного поля.
  • Объём высок, и потерянная строка допустима, если вы так сказали, — телеметрия единственное место в контракте, где объявленная потеря законна.
  • Не нужно, когда кто-то обязан отреагировать: у события телеметрии подписчиков нет вовсе; факт, который другие обязаны услышать, — это игровой Event.

Кто что делает

ActorМожетНе может
any actorобъявлять типы в схеме; испускать от своего имени, по одному или батчем; читать объявленные типызаполнять контекст; читать, запрашивать или агрегировать испущенное
backend-serviceто же самое, а также испускать от имени игрока по делегированиючитать телеметрию — разрешения на чтение нет, потому что нет операции чтения

Одним взглядом

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

Определение — это схема, поэтому приходящее несёт именованные типизированные поля, а не JSON-блоб; и объявляется оно в собственной несущей форме, а не игровым Event с флагом, поэтому понять, что это, можно из Declaration, ничего не запуская.

Модель

Что объявляет тип события телеметрии.

ОбъявляетЧто это
nameсобственное имя типа
fieldsтипизированные системой типов платформы; маска полей к ним применяется так же, как везде
schema versionобязательна и не выводится из версии SDK: воронка сравнивает события, собранные под разными сборками игры, и без версии сравнение молча мешает несравнимое
samplingкакая доля событий этого типа проходит. Объявляется на типе, никогда не выбирается реализацией по нагрузке: доля, меняющаяся сама, делает воронки несравнимыми между днями, и замечают это только после того, как по ним приняли решения
loss toleranceтерпит ли этот тип потерю. Телеметрия — единственное место в контракте, где объявленная потеря законна
deletion behaviourкак удаление игрока доезжает до этого типа — удалением или анонимизацией. Политику объявляет студия; модуль её выполняет

Что верно про любое событие телеметрии.

ВсегдаЧто это
no addressing targetни получателя, ни Group'ы, ни подписки. Желание кому-то адресовать — признак того, что нужен игровой Event
contextпринадлежит платформе: она добавляет Actor или маркер анонимности, сессию, среду, версию сборки и момент по объявленным часам. Вызывающий заполнить его не может: вызывающий, подставивший Actor или версию сборки, получит выведенные значения вместо переданных
the sampling shareедет вместе с событием: без неё абсолютное число из пришедшего не восстановить
lossнаблюдаема в агрегате: доля недоставленного за период доступна потребителю; поштучная наблюдаемость не обещана, потому что на объёмах телеметрии сообщение на каждую потерю само стало бы потоком
emissionне идемпотентно: два вызова — это два факта, и ключа идемпотентности оно не принимает: подавление второго потеряло бы данные. Установить исход потерянного испускания нельзя, и не нужно: терпимость к потере объявляется заранее, сразу для всех вызовов
the eventsи есть история: собственной модуль не держит

Ошибки

  • Необъявленный тип и поле, не соответствующее схеме типа, — отказы валидации; ни то ни другое не является сюрпризом рантайма, потому что тип доезжает до админ-консоли из своего Declaration.
  • Слишком большое событие — отказ валидации; поля никогда не срезаются молча.
  • Превышенная частота отвечает в категории рейт-лимита, неся время, до которого повтор бессмыслен.
  • Отбрасывание по сэмплированию не ошибка, и допустимая потеря тоже. Вызов был выполнен, а отбрасывание — объявленное поведение; сообщать о любом из двух как о сбое означало бы сделать объявленное поведение неотличимым от неисправности.
  • Батч либо целиком, либо поэлементно, и что именно — объявлено, а не «как получилось».

Ограничения

Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.

  • Частота событий на Actor — отказ по рейт-лимиту со временем. Превышение частоты никогда не роняет молча: либо этот отказ, либо объявленное отбрасывание по сэмплированию, и третьего исхода нет.
  • Размер одного события — отказ валидации, никогда молча срезанные поля.
  • Размер батча — отклоняется до отправки, а не применяется частично.
  • Объявленных типов на проект — новое Declaration отклоняется на деплое, а не в рантайме.
  • Полей в типе — то же самое, на деплое.
  • Период удержания — по его истечении событие недоступно по объявленному периоду.

Путь пользователя

Смертельный удар становится типизированным событием телеметрии, сэмплированным своим Declaration и посчитанным потом. Способность и Stat, которые его несут, — пресеты Entity, а не модули.

Beyond the SDK

Операторский план

То, чего в SDK намеренно нет. Жизненный цикл проектов и сред, деплой и откат, биллинг, администрирование организации и пользователей, маршрутизация кластера — всё это принадлежит админ-панели, CLI и поверхности MCP, а не игровому коду. Единственное намеренное исключение — Schema as Code: схема это поверхность разработчика, поэтому она в SDK.

Одна модель, два плана

План SDKОператорский план
Достижим изигрового кодапанели управления, CLI, MCP
ДержитRooms · Entities · игроков · коммерцию · Leaderboardsпроекты и среды · деплой и откат · биллинг · администрирование организации и пользователей · маршрутизацию кластера
Работает вDeclarations, Hooks, Events, Operationsсобственных экранах панели

У них одна модель: Declaration, которое вы отправляете, — то самое, которое отрисовывает панель. Через линию не переходит авторитет: игровой код не может деплоить, выставлять счета или переносить арендатора.

У каждой поверхности SDK — каждого модуля и пресетов, объявленных на Entities, — есть операторский двойник в панели управления, где те же Declarations смотрят и правят с другой стороны:

Поверхность SDKЧто видит оператор
Schema as Code / Data & SubscriptionsEntities, миграции, браузер записей, сохранённые виды, импорт/экспорт
Entityмашины состояний, осмотр экземпляров
Entity Presetsтаблицы дропа, определения способностей, Stats и снарядов, пресеты World Objects — правятся на живую
Access & Rolesсетка ролей: роли × операции, фильтры строк, маски столбцов
Extensibilityцепочки сценариев с переопределениями, разрешённый порядок, трассы вызовов
Roomsпарк: Rooms, здоровье Tick, размещение, статус слива
Matchmakingочереди, летящие тикеты, кривые ослабления
Catalog & Commerceкаталог, расписание витрин, чеки, возвраты
Leaderboardsциклы, правка записей (с аудитом), частота отправок
Auth & Playersпровайдеры, сессии, баны, сценарий входа
Files & UGCассеты, очереди разбора UGC, квоты
Analyticsдашборды, форвардеры, отставание приёма
Mapкарты и наборы препятствий, живые экземпляры
Visibility / Collision / Locomotion / Prediction & Lag Compнастройка на Room'у: правила, пары откликов, окна, стоимость пакета на Actor
Groups / Messagingбраузер Groups, шаблоны, фильтры модерации, расписания
Botsпрофили, квоты заполнения, эндпоинты мозгов
Inventory / Profileвладения и переносы, виды и наборы владеемых Entities

Правило дизайна. Возможность SDK без поверхности в панели невидима для live-ops; поверхность в панели без возможности SDK — ложь. Модули поставляют обе половины вместе, а Declaration, написанное в любом из двух мест, — одна и та же модель в обоих.

Путь одного Declaration: schema-author его пишет, playserv push его везёт, panel отрисовывает его для operator, и перенастройка приземляется в Rooms, которые уже исполняются.

Доступ агентов

Всё, что показывает панель, достижимо и инструментами: платформа выставляет поверхность MCP (то же API, которым пользуется панель), поэтому ИИ-агенты и скрипты эксплуатируют проекты (bootstrap, схема, записи, игроки, деплои) под той же моделью доступа, что любой другой Actor.

Beyond the SDK

Под капотом: транспорт и хаб

Архитектурный справочник, а не поверхность, которую вы зовёте. Ничто на этой странице не появляется в API, против которого вы пишете: нет сокета, который надо открыть, канала, который надо выбрать, конверта, который надо заполнить, и повтора, который надо запланировать. Ваш игровой код никогда не встречается с механикой этой страницы — в этом и смысл. Основные понятия называют стек; механизм живёт только здесь. Он здесь для того, чтобы архитектор мог проверить, что SDK делает с оборванным соединением, выключенным модулем или сообщением, которое обязано прийти ровно один раз.

Стек слоёв

The runtime stack from your code down to the transports, with the line below which you never call anything

Пять слоёв, сверху вниз: пользовательское пространство, модули, Primitives, хаб и транспортные адаптеры под ним. Два верхних — пользовательское пространство; всё ниже — собственное дело SDK.

  • Пользовательское пространство — ваш код. Он видит модули, и словарь на этом заканчивается.
  • Модули — прикладной слой: Rooms, Matchmaking, Inventory, Leaderboards. Они образуют граф, а не дерево, и именно это хабу приходится разрешать, когда один из них выключен (Inheritance & Composition — сама эта форма).
  • Primitives — первая реализация, на которую ссылается всё: данные, Events, RPC, Groups. Модуль — это именованная сборка Primitives плюс собственные правила.
  • Хаб — контроллер: внедрение зависимостей, монтирование модулей, пользовательская сессия, восстановление состояния, качество доставки сообщений и маршрутизация каждого входящего сообщения в модуль, смонтированный под него.
  • Транспорты — адаптеры к протоколу. Их несколько; хаб обращается с ними одинаково.

Транспорты — это адаптеры

Транспортов будет больше одного, и различаются они так, что иначе эти различия протекли бы в каждый модуль:

ОсьДиапазон
Формауправляемый сообщениями или управляемый запросами
Каналыодноканальный или многоканальный
Состояниес восстановлением состояния соединения или без
ПротоколTCP или UDP

Сегодня это WebSocket, UDP-транспорт PlayServ и обычный HTTP. Каждый — адаптер за собственными деталями реализации, и каждый выставляет наверх одно и то же: транспортную сессию. Хаб держит сессию, а не сокет, поэтому ничто выше адаптера не рассуждает о сетевом интерфейсе.

Объявленная граница. Один транспортный канал и одна транспортная сессия за раз. Держать бэкендовый транспорт для Leaderboards, пока транспорт master-client несёт живую сессию, — вне объёма, и API этого не обещает: ни одна сигнатура не имеет смысла только при нескольких открытых каналах. Откроет ли это более поздняя версия, решается вместе с инвариантной поверхностью на проект; до тех пор контрактом является форма с одной сессией.

Хаб прячет транспорт полностью

Вниз хаб говорит на интерфейсе транспорта. Наверх он предлагает состояние, Events и пользовательскую сессию. Код модуля и игровой код равно не способны понять, какой транспорт снизу, как хаб сбатчил вызов и что он сделал, чтобы вернуться к непротиворечивому состоянию после разрыва.

  • Пользовательская сессия принадлежит хабу, а не модулю. Переподключение, возобновление и восстановление состояния происходят один раз, в хабе, для всего, что на нём смонтировано.
  • Качество доставки сообщений принадлежит хабу, а не модулю данных. Конверты, повторы и упаковка — механика хаба.

Качество доставки — ровно три уровня

Модуль объявляет только ту гарантию доставки, которая ему нужна:

УровеньСмысл
at least onceдоставляется повторно до подтверждения; получатель терпит дубликаты
at most onceотправлено один раз, повторов нет; потеря допустима
exactly onceдедуплицировано и подтверждено; дорогой, применяется там, где требуется

Это Declaration и есть весь разговор о доставке. Как гарантия достигается — не дело модуля, и не ваше.

Внедрение зависимостей, монтирование и выключенные модули

Хаб инстанцирует модули и монтирует их — в корень или в пространство имён, — разрешая зависимости каждого модуля от Primitives и от других модулей. Сборка, которой модуль не нужен, его не монтирует. Монтирование идёт по пространствам имён, и второй модуль, претендующий на уже занятую точку монтирования, отклоняется в момент монтирования: композиция падает там, а не на первом вызове в него.

Поскольку модули образуют граф, выключение одного имеет последствия ниже по течению, и хаб берёт ровно один из двух путей:

  1. Выключить зависимую цепочку. Каждый модуль, которому нужен отсутствующий, тоже выключается, и его интерфейсы отсутствуют, а не падают.
  2. Объявить деградированную функциональность. Зависимые остаются смонтированными и объявляют, чего они больше не могут.

Третьего пути нет. Молчаливая полуработа — смонтированный модуль, тихо роняющий операции, которые он больше не может выполнить, — тот самый способ сломаться, ради предотвращения которого это правило существует, и поэтому выключенная зависимость наблюдаема, а не загадочна.

Почему вы ничего из этого не встретите

Каждое обещание на модульных страницах держится выше этой линии: изменение Entity и есть сетевая операция, Hook — типизированная функция, вход — один вызов. Имена стека до вас доехать могут — Основные понятия указывают сюда, — но обещание в том, что вы никогда ничего из этого не зовёте, а не в том, что слова секретны. Слои ниже существуют, чтобы эти обещания пережили смену транспорта, и страница, которую вам никогда не приходится читать, — мера того, что это работает.

Что вы встретите — контекст доставки, на котором исполняются ваши обработчики, когда заканчивается handle и in-memory реализацию, против которой вы тестируете, — это страницей выше: Потоки, время жизни и тестирование.

Start here

PlayServ SDK

Ігровий бекенд, який постачається разом із геймплеєм. PlayServ — це backend-as-a-service для живих ігор: студія веде бекенд своєї гри — дані, гравці, Rooms, Matchmaking, комерція — не хостячи його. SDK — це те, як ваш код, на сервері й у рушії, працює з цією платформою.

Ця сторінка — короткий список того, що тут справді інакше. Усе нижче вирішують один раз на проєкт і налаштовують, а не пишуть; те, що ви викликаєте, живе на сторінках модулів, і кожна секція тут закінчується, називаючи ту, якій воно належить.

One declaration you write, and the five things that happen with no further code from you

Симуляція — не ваш код

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Rooms, колізії, рух, передбачення і синхронізація виконуються всередині платформи. Ваша гра — це Declarations (entities, карти, здібності, таблиці дропу, політика синхронізації), Hooks (ваші правила, які викликаються на іменованих кроках), Events (підписуйтеся, не опитуйте) і Operations (те, що ви просите чи наказуєте). Кожна сторінка модуля організована рівно навколо цих чотирьох.

Те, що ви пропускаєте, конкретне: ігровий цикл, складання знімків, кодувач Deltas, розв'язання колізій, інтегрування руху, обробка перепідключень, валідація влучань із компенсацією лагу. Див. Getting Started, який будує саме це.

Мутація оголошеного стану і є мережевим викликом

Ніякого send немає. Ви оголошуєте, як синхронізується поле — один атрибут поруч із полем, — і його зміна є мережевою операцією: Deltas відносно останнього підтвердженого стану, аспект як одиниця політики, пріоритет і частота надсилання, утримуване вікно, Hooks до і після зміни. Далі по ланцюжку ви не пишете нічого.

Той самий хід поширюється на все інше оголошене: Event, RPC, Group, вісь Leaderboard. Declaration — це вхід для типізованого API, для адмінської панелі, що його рендерить, і для кодогенерації кожної прив'язки, — і саме тому ви версіонуєте Declaration, а не згенерований код. Див. Data & Subscriptions і Schema as Code.

Інтерфейси йдуть за Actor'ом, а не за стороною

Немає ані клієнтського SDK, ані серверного. Постачається один SDK, а те, що викликові дозволено робити, вирішує Actor за ним — гравець, сервіс, мозок бота, оператор.

Випадок, заради якого це зроблено, — машина гравця, яка створює Room'у і далі її веде: master-client, що тримає інтерфейси room-owner і більше нічого. У збірки room-visitor немає ані kick, ані close — вони не вимкнені, а відсутні.

Права складають з атомарних дозволів, тож вбудованих ярусів ролей немає, а роль закриває дані аж до рядка і колонки. Див. Авторитетність, а те, як пишуть грант, — Access & Roles.

Один дизайн, звужений двічі — над одним стеком

Як він написаний

SDK — це один дизайн із двома звужувальними виходами, і порядок тут є правилом: ніщо не спускається рівнем нижче, доки рівень вище справді не може цього понести. Спільні принципи однакові в кожній прив'язці. Форма мови бере лише те, чого її парадигма не може виразити спільним способом: у C# є атрибути, у Python — декоратори, те саме Declaration, написане так, як кожна мова вже пише цю ідею. Форма рушія бере лише те, що рушій переробляє поверх своєї мови: в Unreal Declaration живе всередині власного макроса рефлексії рушія, а Unity C# — теж не серверний C#.

Як він виконується

Ваш ігровий код звертається до модулів і більше ні до чого. Модулі зібрані з чотирьох Primitives — Events, RPC, дані і підписки, Groups. Під ними сидить хаб, якого ви ніколи не викликаєте: впровадження залежностей, монтування модулів, сесія, відновлення стану, якість обслуговування повідомлень. Під ним — адаптери транспорту, по одному на протокол, і те, який із них несе виклик, ваш код не вирішує і не помічає.

Обидві половини повністю: Як влаштований SDK, із завершенням у Під капотом.

Модулі компонуються; ніщо не успадковується

Немає ані базового модуля, від якого походити, ані ієрархії, яку розширювати, — модулі утворюють граф, бо дерево дозволяє лише гілки, а справжні фічі їх перетинають: Matchmaking резервує місця в Rooms, дроп кладе предмети через карту, чат живе всередині Room'и.

«Успадкування» покриває тут чотири різні механізми, і їх варто розрізняти: RPC однієї Entity — частина цієї Entity і більше ніде не існують. Пресет — це іменований набір аспектів, а не базовий клас. Перевизначення кроку платформи — це атрибут на вашій заміні. Модуль позичає інший через декоратор, який звужує позичений інтерфейс. Див. Успадкування і композиція.

Хто що бачить, оголошують, а не фільтрують на клієнті

На сорока гравцях знімок усієї Room'и годиться; на двохстах уже ні, і ширша труба цього не полагодить. Правила інтересу вирішують, хто отримує який зріз, а пакети на кожного Actor'а і розсилка — це два режими доставки однієї оголошеної моделі: перехід між ними — це налаштування, а не переписування. Далекі речі деградують через оголошені рівні деталізації, перш ніж зникнути.

Та частина, яка є властивістю безпеки, а не смуги пропускання: стан, що не має протекти, ніколи не надсилають. Туман війни і поля лише для власника відсутні в пакеті, а не приховані на клієнті. Спостерігачі, адміни й повтори дістають свій ширший огляд, тримаючи ширший грант, — знову авторитетність, а не окремий випадок. Див. Visibility.

Що переживає втрату хоста

Смерть хоста не завершує матч. Стан Room'и не копіюється між хостами, поки триває гра — єдиний власник і є тим, що тримає впорядкування поза консенсусом, — а переживаним матч робить те, що стан оголошено, а оголошений стан зберігається поза хостом. Його знімають на оголошеному інтервалі, і заміна продовжує з останнього знімка.

Отже, у заміни стан цілком — але станом на той знімок. Повний, а не поточний. Коштує це гри від останнього знімка; чим не покрито — усім, що ви тримали лише в акторах рушія. Деплой користується тим самим механізмом, мінус втрата: злив хоста — це шлях відмовостійкості, запущений навмисно. Див. What Survives Losing a Host і Rooms — про пільгове вікно, через яке гравець повертається.

Будь-який крок платформи може бути вашим

Кожен сценарій платформи — це ланцюг зареєстрованих функцій, і ви заміняєте ланку або огортаєте її. Вхід, валідація входу в Room'у, покупка, подання, вивантаження — кожне з них є іменованим кроком, а ваша заміна оголошується атрибутом, з версіями, що обираються за умовою, і власним кроком платформи як запасним.

Саме це конкретно означає «платформа, яку можна налаштовувати», і саме це стоїть замість того, щоб віддавати вам наші вихідники: ви заміняєте кроки, а не форкаєте те, що їх виконує. Див. Extensibility.

Одна поверхня, шість мов

Один контракт, шість проєкцій: C#, TypeScript, Python і Go на сервері; згенеровані Unreal C++ і Unity C# у рушії. Кожен приклад коду на цьому сайті показує всі шість, а там, де в прив'язки немає поверхні для кроку, вкладка називає причину замість того, щоб удавати: крок виконується поза рушієм або право зробити цей виклик тримає інший Actor.

Два наслідки, які варто знати до вибору мови: RPC бере об'єкти SDK за посиланням, а не сплющеними DTO, і асинхронні примітиви є частиною ядра, а не прикрученими збоку — channels, streams і адресація по Group, тож ви можете поговорити з цілою Group і зібрати відповіді або споживати файл шматками, поки він ще вивантажується.

Є також детермінований in-memory хост, який виконує ваш ігровий код без бекенда за ним і з часом під вашим керуванням, тож тест є тестом, а не гонкою, — див. Потоки, час життя і тестування.


Те, чого в SDK навмисно немає — деплої, білінг, адміністрування організації та користувачів, — живе на операторському плані. Бічна панель — це мапа всього іншого; Getting Started — найкоротший шлях усередину.

Start here

З чого почати

Ігрова арена (карта, танки, стрільба, дроп), оголошена від краю до краю. Ніщо нижче не є ігровим циклом: симуляція виконується всередині платформи, і це весь код, який тут є.

Перш ніж почати. Проєкт із середовищем dev (створений на операторському плані, якому належить цей життєвий цикл), CLI playserv, що в нього ввійшов, і пакет SDK для вашої прив'язки — більше у вашу гру нічого не ставлять.

Шлях користувача

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

Кожен виклик, який робите ви, — це один із прикладів нижче; кроки між ними — це платформа, що діє за сказаним у Declaration. Здібність, стата і таблиця дропу на рисунку — це пресети entity: Declarations на entities, а не власні модулі.

1. Оголосіть світ

Entities — це ваша схема плюс їхні живі аспекти. Один атрибут на поведінку, поруч із полем, яке він описує:

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

Мутація поля [Sync] і є мережевою операцією. Немає ані знімка, який треба скласти, ані виклику send, який треба зробити.

Жоден із тих типів не вам визначати, і кожен належить одній сторінці:

У блоціПриходить із
Vector3, Statбазового пакета вашої прив'язки
Body і форми тілCollision
Motion і п'ять моделей рухуLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectпресетів entity, які їх уживають
EntryRequest, Verdict, StatEventpayload'ів Hooks, які передає модуль, куди ви чіпляєтеся
SeatMatchmaking
Scope, області синхронізаціїVisibility
Tick, частоти Tick'аRooms

Переліки закриті. Правило, якого не покриває жоден член, пишуть предикатом, а не новим членом: [Aspect("loadout", Visible = "owner == caller.player")] — це те, як виражають видимість на кожне поле, коли Scope.Owner не зовсім те правило, яке ви мали на увазі (Data).

2. Оголосіть Room'у

Шаблон Room'и каже, чим є сесія, і називає Declarations, на які він спирається. Немає ані класу Room, який треба успадкувати, ані методу Tick, який треба заповнити, бо нутрощі Room'и належать платформі:

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

Ідентифікатори в лапках — це ключі контенту, а не вільний текст:

КлючЩо він називає
rockреквізит у наборі перешкод карти (Map)
ammo.shell, railgunпредмети каталогу (Catalog & Commerce) — і саме так постріл списує набої, а підбирання надходить у торбу
battle, arena, crate-loot, shellключі, які реєструють ці чотири Declarations

playserv push відхиляє Declaration, чийого ключа не існує в середовищі, куди воно націлене, тож ключ із помилкою падає на деплої, а не на першому застосуванні. Хоч би де жив шаблон, він лишається переналаштовним без передеплою рушія: відправлена модель — це те, що live-ops править у панелі.

3. Напишіть свої правила як Hooks

Hooks — це хмарні функції, які платформа викликає на іменованих кроках. Типізовані на вході й на виході — жодних мішків контексту, жодних логерів у сигнатурі:

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Ворота Before можуть відмовити в кроці; спостерігач After виконується, щойно той закомітився, і відмовити не може. Тож стартовий набір, який не застосувався, коштує 20 снарядів, а не входу. Решта — в Extensibility.

Майже ніщо в тому блоці не робить тієї роботи, яку начебто робить:

РядокЩо це насправді виконує
спрацьовує Stats.Depletedстата, що досягла своєї підлоги — 0 для Hp, бо Declaration задало лише Max
перехід смертіAtMin = "death" із секції 1; Hook додає наслідок, а не перехід
шкода[Effect(Damage = 35)] на снаряді, застосований платформою на влучанні
RollAtзгенерований на Declaration [DropTable] — і саме тому воно partial, і саме тому вкладка Go читається як drops.RollAtCrateLoot
підбираннянаїзд на здобич атомарно переносить предмети в inventory гравця; цей перенос і є Event'ом changed, що його рендерить HUD

Згенеровані типи для рушія

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

Запускайте його (або дайте запускати CI) після кожного push схеми — типи перегенеровують, а не правлять руками, і згенерований Tank є відправленим Tank. playserv push читає проєкт рушія рівно так само, як читає серверний проєкт: специфікатори UHT і атрибути C# і є Declaration, тож навести CLI на проєкт UE чи Unity — це весь крок експорту. На який потік надходить зворотний виклик і коли закінчується підписка, фіксує модель рантайму — Потоки, час життя і тестування.

4. Підключіть клієнта

Клієнтське API симетричне: ті самі модулі, а те, що збірці дозволено викликати, вирішує ключ, під яким вона працює. Збірка рушія несе ключ гравця — тут projectKey, облікові дані для одного проєкту й одного середовища, випущені в панелі й покладені всередину збірки. Він не називає ролей: ролі розв'язуються на боці сервера на кожен запит, а гравець за ними приходить із SignIn. Прив'язки рушіїв тут першокласні; серверні прив'язки ведуть ту саму поверхню в headless-режимі (мозок бота, навантажувальний тест, операційний інструмент):

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

Чотири виклики, і кожен відповідає своїм:

ВикликЧим відповідає
Findрозміщеним тікетом і місцем, заброньованим у Room'і. Бронь тримають на термін, який оголосив template; згаяти її коштує місця, а не права грати
Joinрозв'язується, коли прибув поточний стан Room'и. Tank, якого template спавнить учаснику, що входить, — частина цього стану, тож My<Tank>() відповідає наступним рядком, а все після Join — живий трафік
Castпостріл — це дієслово здібності, а не друга поверхня: Declaration [Projectile] і є здібністю з балістикою згори, тож Cast перевіряє відкат, боєзапас і ціль так само, як для ривка чи лікування (Entity Presets)
Abilitiesгенерується: codegen збирає оголошені здібності та снаряди в один тип на кожен binding

Відмови надходять типізованим Problem платформи — код плюс причина для людини. C#, TypeScript, Python і Unreal його кидають; Go повертає його значенням помилки, і саме тому кожен виклик у тій вкладці перевіряють. Виділений сервер Unreal або master-client натомість виконує той самий бінарник під хостовим ключем, і його ролі несуть рядки mc: Rooms → Хостинг Room'и — це та поверхня від краю до краю, а Access — це там, де оголошують ключі й ролі за нею.

5. Відправте і грайте

playserv push          # схема + Declarations + Hooks, один деплой
playserv open battle   # Room у dev-середовищі, жива в панелі

playserv push сканує проєкт, у якому виконується, — атрибутні Hooks і Declarations — і розгортає в середовище, куди ви націлилися (--env dev типово). Сам playserv schema push рухає лише модель, а playserv schema diff — це те, проти чого диференціюють push (Schema).

Push застосовується цілком або не застосовується, і його відхиляють, а не зливають, якщо розгорнута схема зрушила з часу вашого diff. Зміна, що зламала б наявні дані, не проходить через push узагалі: вона стає міграцією, яку ви спершу читаєте, а потім запускаєте чи скасовуєте (Schema). Перенести модель із dev у prod — це акт операторського плану, а не виклик SDK (операторський план).

playserv open battle створює одну Room'у з відправленого шаблону battle і відкриває її в панелі, де стан Room'и та її учасників можна оглядати, поки ви проти неї граєте. Панель тепер показує шаблон, карту, таблицю дропу і Hooks: ту саму модель, яку ви написали в коді, і яку там теж можна редагувати.

Числа, яких ви не обирали

Capacity = 8, Hz30, Hz = 10 і Cooldown = 1.5f — це налаштування цієї гри, а не стелі. Власні ліміти платформи сидять над ними, і кожен оголошений разом із тим, що викликач спостерігає на межі:

На межіЩо дістає викликач
вхід понад місткість або в закриту Room'уconflict — варто повторити, коли звільниться місце
створення Room'и понад ліміт на проєкт чи на Actor'авідхилено, і нічого вже створеного не розпускають
створення Rooms чи вхід надто швидковідмова рейт-ліміту, що несе час очікування
payload Event'а понад стелю Room'ивідхилено до відправлення, ніколи не обрізано
читання понад стелю рядків для ролірядків на цю стелю плюс маркер, що каже, що його обрізали

Самі числа задають на кожне середовище, і вони надійдуть із лімітами платформи; поведінка на межі на них не чекає (Rooms, Access, Auth).

Куди далі

  1. Приклади, секція одразу після цієї: Leaderboard у Tanks, аптечки в Tanks або рецепт щоденного турніру для мета-петлі — по одній справжній фічі кожен, і кожен крок веде на сторінку модуля, якому належить щойно вжите.
  2. Як працює SDK, коли його форма починає важити більше за наступну фічу: Основні поняття — це словник, а чотири статті відповідають на хто викликає (Авторитетність), як пишуть грант (Access & Roles), з чого зроблений SDK (Як влаштований SDK) і як він виконується (Потоки, час життя і тестування).
  3. Далі модулі. Кожна сторінка модуля має ту саму анатомію — теза, Actors, коли застосовувати, шлях користувача, приклади, модель, — тож друга читається швидше за першу, а п'ята забирає хвилини. Entity і Data — це ті двоє, на які спирається все решта.

Шляхи читання за ролями

Хоч би яка була ваша роль, читайте спершу Авторитетність — один SDK і грант на кожного Actor'а є спільною передумовою — з відкритими поруч Основними поняттями.

ВиЧитайте по порядку
Розробник ігрового клієнта (Unity · клієнт Unreal · TS)Auth → Matchmaking → Rooms → Entity → Data, далі за фічами: Inventory · Leaderboards · Messaging · Profile
Серверний розробник (C# · TS · Python · Go)Schema → будівельні блоки → Entity → Extensibility → Access, далі модулі, чиї Declarations ваші: Rooms · Matchmaking · Leaderboards · Commerce
Розробник виділеного сервера UnrealRooms (Хостинг Room'и) → Bots → Locomotion · World Objects → Map → Що переживає втрату хоста
Examples

Leaderboard у Tanks

У Tanks, зразковій арені з Getting Started, немає Leaderboard. Цей урок, який ви можете взяти будь-коли після Getting Started, додає тижневу таблицю вбивств за три кроки: оголосити таблицю, відправляти з Hook'а вбивства, читати її в клієнті. Кожен крок веде на сторінку модуля, якому належить щойно вжите, тож урок вчить, показуючи, а не переказуючи.

Крок 1 — оголосіть таблицю

Таблиця — це Declaration: яке поле її ранжує, як складаються повторні відправки, коли вона скидається і хто має право відправляти. Aggregation.Increment додає кожну відправку до поточної суми, тож одне вбивство — це одне очко. Submit.ServerOnly — типове значення, і воно закриває таблицю для клієнтів, а саме це й робить крок 2 єдиним шляхом усередину.

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

Відправте його командою playserv push — і таблиця з'явиться в панелі, порожня, з уже запланованим понеділковим циклом: понеділок 00:00 UTC, оскільки розклади в UTC. Осі, яких ви не задали, тримають свої типові значення. Повний перелік осей — власник, ключ порядку, поля показу, турнірні правила — див. у Leaderboards.

Крок 2 — відправляйте з Hook'а вбивства

Tanks уже завершує життя через поріг HP, оголошений на танку: на нулі HP спрацьовує перехід death, і платформа викликає Hook після нього. Hook — це хмарна функція, типізована на вході й на виході, тож відправка вбивства всередині неї — це один рядок.

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By — це нападник, якого несла шкода, тож ніяка бухгалтерія не стежить, хто в кого стріляв. e.Id — власний id Event'а, і передати його ключем ідемпотентності — саме те, чого потребує таблиця з Increment: повторно доставлений Event вбивства рахується один раз, а не двічі. Точка Hook'а, гарантії порядку і контракт вето — це Extensibility; поріг, який його запускає, — це пресет стат на танку, а рядок рахунку, який він пише, — звичайні дані, які можна запитувати.

Крок 3 — читайте таблицю в клієнті

Два читання покривають увесь UI: верх таблиці й вікно навколо локального гравця — п'ять рядків вище, п'ять нижче, плюс власний. Обидва повертаються ранжованими записами з убивствами й іменем показу, готовими до прив'язки до списку. Підписка тримає панель свіжою, поки триває матч, і доставляє лише ранг локального гравця.

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

Понеділкове скидання закриває цикл, а не видаляє його, тож минулотижнева таблиця лишається читабельною за своєю міткою — той самий виклик Top з аргументом cycle:. Hook нагороди на закритті циклу — природний четвертий крок, описаний у Leaderboards.

Куди далі

  • Leaderboards — осі, цикли, турніри й передвідправний Hook, що зрізає підозрілі рахунки.
  • Extensibility — кожна точка Hook'а, по порядку, з контрактом вето.
  • Entity presets — поріг Stat, що запустив убивство на кроці 2.
  • Аптечки в Tanks — інший приклад на Tanks: два Declarations і один Hook.
  • Getting Started — арена Tanks, яку цей урок розширює.
  • Основні поняття — словник, який припускає кожна сторінка модуля.
Examples

Аптечки в Tanks

До
танк, що отримав шкоду, лишається пошкодженим, аж поки не помре. Шляху назад немає, тож кожен бій — це зворотний відлік, а рухатися ареною немає причини.
Після
аптечки з'являються по арені, рознесені одна від одної й подалі від тих, хто б'ється. Наїхавши на одну, ви лікуєтеся. Більше в грі нічого не змінюється — як не змінюється й код Room'и, бо його немає.

Це другий урок на Tanks. Він робиться у три кроки й без жодного нового модуля: Declaration для ящика, Declaration для того, де ящики з'являються, і один Hook для того, що робить підбирання. Беріть його після Getting Started, у будь-якому порядку з уроком про Leaderboard.

Крок 1 — оголосіть ящик

Ящик — це Entity з двома застосованими пресетами і тілом, яке повідомляє про контакт, нікого не зупиняючи. Response.Pass на шарі pickups — це те, що робить його підбиранкою, а не перешкодою: про контакт повідомляють, рух проходить наскрізь.

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

Дві речі, яких вам не довелося писати: де ящик малюється (клієнт уже рендерить оголошені об'єкти світу) і як його позиція доходить до клієнтів — [Sync] і є мережевим викликом.

Належить Entity Presets і Collision.

Крок 2 — оголосіть, де з'являються ящики

Розміщення — теж Declaration, і саме цей крок вирішує, чи відчувається фіча чесною. Розрідження не дає ящикам збиватися в купу, відстань від гравців не дає їм спавнитися просто в дуель, а правило без повторів не дає тому самому місцю бути відповіддю щоразу.

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

Допустимі позиції приходять із Map: таблиця просить місце на шарі ground, а карта відповідає таким, до якого справді можна дістатися, тож ящик ніколи не приземляється всередину стіни.

Належить Entity Presets і Map.

Крок 3 — лікуйте у разі підбирання

Один Hook, і це єдиний код в уроці. Він виконується на платформі як хмарна функція, і саме тому він не з'являється на жодній вкладці рушія.

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

Три речі, які цей Hook отримує задарма, і кожна з них — причина, чому урок такий короткий:

  • Відмова типізована. Танк на повному здоров'ї дістає already at full health із причиною, яку клієнт може показати, а ящик лишається на місці для того, кому він потрібен.
  • Правка стати авторитетна на сервері. Клієнт не може її попросити, тож для підбиранок не треба писати жодного винятку в античіті.
  • HUD оновлюється, і йому не треба про це казати. Adjust випускає changed; клієнт уже підписаний на оголошені стати танка. Ви не писали мережевого повідомлення.

Належить Extensibility і Entity Presets.

Що змінилося, а що ні

ДоПісля
пошкоджений танклишається пошкодженим, аж поки не помреможе відновитися, рухаючись ареною
код Room'инемаєтак само немає
нових змонтованих модулів—жодного: дві Declarations і один Hook
винятків в античіті—жодного: лікування авторитетне на сервері, як і будь-яка зміна стати

Куди далі

  • Entity Presets — генератор дропу, об'єкти світу і модель стат, на які спирався цей урок: усі три — пресети entity, а не модулі.
  • Collision — шари, відповіді й різниця між контактом, про який повідомляють, і контактом, який блокує.
  • Map — як обирається допустима позиція і що означає «досяжна».
  • Extensibility — кожна точка Hook'а по порядку, з контрактом вето.
  • Leaderboard у Tanks — інший приклад на Tanks.
Examples

Щоденний турнір

Що ви отримаєте: щоденний турнір із вікном вступу, засіяними Rooms і виплатою призів — цілком побудований із Declarations і Hooks на модулях, сторінки яких у вас уже є. Нічого нового тут немає; це Leaderboards, Matchmaking, Rooms, Commerce і Messaging, скомпоновані для однієї мета-петлі.

Крок 1 — оголосіть таблицю з вікном вступу і лімітами спроб

Турнір — це звичайне Declaration leaderboard плюс обмеження участі: вікно вступу, стеля учасників і спроб на цикл. У підрахунку очок не змінюється нічого — ключ порядку, агрегація і скидання лишаються рівно такими ж, як на будь-якій таблиці.

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

Відправте його — і панель покаже порожню сітку із запланованим вікном. joinRequired: true робить учасників членством, а не «усіма, хто грає», тож відправка від неучасника відхиляється. Решту переліку осей і те, що робить кожне обмеження на своїй межі, див. у Leaderboards.

Крок 2 — вікно відкривається: паті входить, Rooms засіваються

Щойно вікно вступу відкрилося, гравці стають у чергу рівно так само, як на будь-який матч: створити чи приєднатися до паті, потім один виклик Find. Матчмейкер розміщує паті в турнірну сітку, а Rooms засіває матч — той самий шлях розміщення й місця, яким користується кожен матч, лише обмежений чергою турніру.

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

Стелі з кроку 1 належать таблиці, а не матчмейкеру: черга розміщує паті, а зустрічає учасника понад стелею чи гравця понад його спробами саме таблиця. Учасник 65 із 64 відхиляється як конфлікт, і нікого не витісняють, а четверта відправка за один цикл відповідає «спроби вичерпано» — теж конфлікт, який знімається щоденним скиданням, а не проханням про дозвіл.

Крок 3 — рахунки відправляються через Hook on-dispose

Rooms не звітують про переможця в Leaderboard самі; цей зв'язок — Hook, той самий контракт Extensibility, що й усюди: типізовано на вході, типізовано на виході, без мішка контексту. Hook on dispose Room'и (Rooms) — останнє, що виконується з фінальним станом матчу в руках, і відправляє він саме звідти.

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner і FinalScore — це поля, які власний шаблон Room'и цієї гри оголошує у своєму стані; платформа не додає до знімка нічого (Rooms — це там, де оголошують стан шаблону). Hook розпуску (Rooms.Disposed) передає фінальний знімок, тож матч ніколи не переобчислюють. Передвідправний Hook із Leaderboards усе одно виконується першим — рахунок у сітці підпадає під той самий контракт «виправ, зріж або відхили», що й будь-яка інша відправка.

Крок 4 — цикл закривається: нагороди видано, гравця сповіщено

Щоденне скидання з кроку 1 закриває цикл рівно так, як це робить будь-яке скидання Leaderboard, і запускає CycleClosed, що несе мітку закритого циклу, — саме ця мітка змушує Hook читати таблицю, яка щойно закрилася, а не порожню, яка щойно відкрилася.

Решту робить один Hook: видає приз через шлях прав Commerce і надсилає результат через Messaging, тож окремої задачі виплати запускати не треба. Сповіщення адресується одному Actor'у, тож топ-8 — це цикл із восьми, кожен зі своїм аргументом rank у шаблоні.

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

Числа з кроку 1 — це значення seed: LiveOps переналаштовує вікно, стелю учасників і кількість спроб у панелі, і наступний деплой не перезаписує зміну мовчки. Зробити з цього тижневий турнір — це одна правка: Reset.Daily() стає Reset.Weekly(DayOfWeek.Monday), а кроки з 2 до 4 лишаються як є.

Куди далі

How the SDK works

Основні поняття

Слова, якими решта цих сторінок користується, не зупиняючись, щоб їх пояснити. Сторінка модуля припускає, що ви вже знаєте, що таке Actor, аспект чи Room, — тут кожне з них отримує однорядкове визначення й посилання на сторінку, де насправді живе механізм, що за ним стоїть. Прочитайте це один раз перед довідником модулів або поверніться, коли виявиться, що слово несе більше ваги, ніж ви очікували.

Три речі завеликі для запису і мають по сторінці: Авторитетність — хто робить виклик і що саме це вирішує; Як влаштований SDK — з чого SDK зроблений; Успадкування і композиція — як модулі стоять один на одному. У цьому порядку вони читаються як один аргумент.

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

Чотири поверхні

Кожен модуль виставляє рівно чотири речі, і кожна сторінка модуля організована навколо них. Це і є модель програмування:

ПоверхняЗначення
Declarationsщо існує і як воно поводиться, написане в коді або в адмінській панелі; в обох випадках та сама модель
Hooksваші правила, які платформа викликає на іменованих кроках; розгортаються як хмарні функції
Eventsте, що платформа каже вам про те, що сталося, — підписуйтеся, не опитуйте
Operationsте, що ви просите чи наказуєте, з функції або з клієнта

Actor

Хто робить виклик. Що викликові дозволено робити, вирішує Actor, який за ним стоїть, а не те, в яку збірку код скомпілювали; аргумент — це Авторитетність, а механізм (атомарні дозволи, складені ролі, InterfaceGrant) — це Access & Roles.

Ця документація бере імена Actors з одного каталогу — пресетів, які постачає платформа. Це набір пресетів, а не закритий список (проєкт називає власних Actors), але кожен рядок actors схеми, кожен рядок «Хто що робить» і кожен чип на діаграмі потоку на цих сторінках уживає рівно ці написання:

player · backend-service · operator · host · moderator · schema-author · architect · bot-brain · room-owner · room-visitor · entry-validator · spectator · match-organizer · warehouse-keeper · seller — і any, коли сторінка має на увазі всіх їх.

Сторінка може додатково ввести сценічну роль для однієї діаграми — описового учасника на кшталт member чи attacker, — якщо її власна проза чи таблиця «Хто що робить» уводить його першою.

Runtime surface

Де виконується код. Ці чотири теги вживають у кожній таблиці операцій:

ТегПоверхня
fnХмарна функція (C# · TypeScript · Python · Go). Авторитетна на сервері; головний дім ваших правил
clІгровий клієнт (Unreal C++ / Unity C#). Симетричне API; ролі відмикають менше
mcПоверхня хоста Room'и: master-client (клієнт, що володіє Room'ою) або виділений сервер Unreal під своїм хостовим ключем
admАдмінська панель / CLI / MCP — там, де SDK і операторський план ділять модель

Це та вісь, яку постійно плутають із віссю над нею. Де виконується код і який інтерфейс Actor'а він тримає — два окремі питання: той самий код тримає ті самі права, хоч би куди його поклали, і різниться лише грант.

Project & Environment

Project — це бекенд однієї гри, зі схемою і її даними в ізольованих Environments (dev, prod). Кожен виклик виконується всередині Project + Environment.

Entity

Центральний іменник. Entity — це Declaration схеми плюс її живі аспекти: дані 0..*, стани 0..*, RPC 0..*, Events 0..*, Hooks та історія змін. Танк, двері, смуга характеристики і квест — усе це entities, які різняться лише тим, які аспекти несуть. Поширені комбінації постачаються як пресети (GameObject, Stat, Character, Interactable, Projectile). Див. Entity.

Expected state

Запит на перехід може назвати стан, якого він очікує, і тоді це або цей стан, або відмова. Запит, що не називає жодного, оцінюється проти стану, який машина тримає тоді, коли платформа його обробляє, — ніколи проти стану на мить відправлення.

Відповідь описує ту мить і нічого не обіцяє про пізніше: чийсь чужий перехід, що набув чинності, поки відповідь у польоті, лишає відповідь істинною і не скасовує її. Тож називайте очікуваний стан, коли наслідок залежить від того, що було раніше, а в інших випадках не читайте відповідь як знімок, що переживе виклик.

Room

Room — це ігрова сесія, а не місце, де виконується ваш код. Платформі байдуже, що її хостить: виділений сервер, master-client або сам бекенд. Нутрощі Room'и наші; ви ведете Room'у ззовні, з хмарних функцій і клієнтів, через Declarations, Hooks, Events і Operations. Див. Rooms.

Channel & Stream

Асинхронні примітиви під усім. Channel — це адресована тема pub/sub: Room, Group, Entity або ваша власна. Stream — це потік шматками в будь-якому напрямку: файли споживаються в міру надходження шматків, запити можуть текти потоком, а RPC може розійтися по Group і зібрати відповіді. Див. Core.

Primitive

Одна з чотирьох цеглин, з яких зібрано кожен модуль: events (оголосити, випустити, підписатися), RPC (викликати через мережу), data and subscriptions (механіка синхронізації) і groups (один список, багато слухачів). Room, чат і пул Matchmaking — це один і той самий примітив Group під різними правилами. Якщо фічу не можна виразити через ці чотири, це дефект дизайну, а не аргумент на користь п'ятого.

Hook contract

Один контракт скрізь: Hook before виконується попереду валідації, отримує типізований payload, може його змінити або відхилити; Hook after виконується, щойно операція закомітилася, отримує запит і результат і може лише додавати побічні ефекти — він ніколи не може завалити операцію. Hooks упорядковані; кожен зареєстрований крок платформи може їх нести. Див. Extensibility.

Delta & Revision

Клієнти отримують стан як Deltas: лише змінені поля, закодовані проти останнього стану, який отримувач підтвердив. Кожен запис несе Revision; умовні записи відхиляються на розбіжності. Одне поняття версіонування обслуговує синхронізацію, конкурентність та історію. Див. Data. (Те, що Unreal називає реплікацією, — який клієнт бачить який стан і як часто — живе тут, а також у Visibility і Prediction. Сторінка Що переживає втрату хоста — це інше: яка машина володіє Entity і яка володітиме нею наступною.)

Tick

Rooms симулюють фіксованим кроком. Кожна зміна стану штампується своїм Tick'ом; синхронізація, передбачення, компенсація лагу й історія рахують у Ticks, а не за стінним годинником. Дані несуть свій справжній час події — саме це робить відмотування й узгодження точними. Див. Prediction.

How the SDK works

Авторитетність

Авторитетність — це абстракція, а не дві збірки SDK. Немає ані клієнтського SDK, ані серверного. Є один SDK, а те, що конкретному викликові дозволено робити, вирішує Actor, який його робить.

Master-client — це ні клієнт, ні сервер

Машина гравця, яка створює Room'у і далі її веде, — master-client, — тримає інтерфейси room-owner і більше нічого. Це не сервер: вона не може робити всього, що може сервер. І це не звичайний клієнт.

Виділений сервер — та сама фігура з іншого боку: той самий клієнт без рендерингу, і окремого SDK йому не треба. Розділяє їх довіра, а не будова, а довіру несе видача.

Інтерфейси йдуть за Actor'ом, а не за стороною

Модуль не виставляє «клієнтське API» і «серверне API». Він виставляє те, що може room-owner, що може entry-validator, що може seller. Клієнт і сервер — це технічні подробиці; Actors — предметна область. Усередині кожної сторінки модуля поверхню згруповано так само: це для цих потреб, а те — для тих.

Роль — це і право, і класифікація водночас

Тут рівно один вимір. Роль несе те, що Actor'у дозволено робити, і вона ж є способом сказати, кому щось адресовано. Ми навмисно не додали поруч другої осі з тегів чи міток: одне оголошувати, одне перевіряти, одне читати в адмінській панелі.

whoami — це те, як питає код. Він називає Actor'а та інтерфейси, які цей Actor відмикає саме зараз, а не статичний список, запечений у бінарник на етапі збірки.

Де виконується код і який Actor він тримає — різні питання

ПитанняВідповіді
Де цей код виконується?хмарна функція · ігровий клієнт · master-client або хост на виділеному сервері
Який інтерфейс Actor'а він тримає?player · room-owner · entry-validator · seller · moderator · backend-service · …

Викладені сіткою, ці дві осі незалежні, і кожна клітинка досяжна:

хмарна функціяігровий клієнтхост Room'иадмін
player✓✓✓—
room-owner✓✓ — власна машина гравця, що хостить✓—
backend-service✓—✓✓

Виділена клітинка — це код, що виконується на клієнті й робить серверну роботу. Він названий — room-owner, — і це така сама видача, як будь-яка інша.

Будь-яка комбінація законна. Хмарна функція не є привілейованою автоматично, а клієнт не є обмеженим автоматично: права походять із гранту, а грант оголошений. Права коду однакові, хоч би де він виконувався, — різниться лише те, що йому дали.

Відкликання діє без перевипуску облікових даних

Облікові дані називають особу. Вони не несуть списку ролей. Ролі розв'язуються на боці сервера, на кожен запит, а це означає, що клієнт ніколи не тримає доказу власних дозволів і не лишається нічого застарілого, що можна далі пред'являти після відкликання.

Два наслідки, обидва названі там, де їм належить:

  • Відкликання ролі набуває чинності без перевипуску облікових даних — див. Видача ролі.
  • Воно стає спостережуваним не пізніше за оголошену межу застарілості кеша прав. Миттєвості ми не обіцяємо.

Де авторитетність оголошують, а не виводять

  • Тип Room'и оголошує свій режим авторитету, і типового значення немає: або Tick веде наша симуляція, або зовнішній авторитет — ігровий сервер студії чи клієнт гравця як master-client. Це Хто веде Tick.
  • Наскільки зовнішньому авторитету вірять щодо результату — це окреме Declaration на типі Room'и: прийняти, перевірити Hook'ом або не приймати. Знову ж таки без типового значення.
  • Які облікові дані розв'язуються в яку роль і що таке хостовий ключ — це Access & Roles.
How the SDK works

Access & Roles

Ролі складають, а не зашивають у код. Атомарні дозволи складаються в ролі; ролі закривають дані аж до рядка й колонки і вирішують, які інтерфейси модулів збірка взагалі бачить. Це заміна поділу ключів на клієнтські й серверні: облікові дані називають особу, а їхні ролі розв'язуються на кожен запит.

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

Коли застосовувати

  • Вам потрібні облікові дані вужчі за «клієнт» чи «сервер» — за ними на кожен запит розв'язуються складені ролі.
  • Доступ до даних має зупинятися на рядках і колонках: обмеження за регіоном, маски PII, підрядники лише на читання.
  • Збірка має бачити лише ті інтерфейси, які відмикає її роль, — kick/close для відвідувача просто немає.
  • Ваш UI має чесно гасити кнопки — CanI обчислює ту саму політику, яку сервер потім і застосує.
  • Не потрібно, коли пресети, що постачаються (player, room-owner, seller, …), уже збігаються з вашими Actors — кожен модуль дотримується їх типово; повний каталог живе в Основних поняттях.

Хто що робить

ActorНа цій сторінці
operatorоголошує ролі й політики, задає ліміти по рядках/колонках, видає ролі, випускає ключі
match-organizerтурнірний персонал із потоку нижче: тримає складений ключ, закриває входи, не може повертати гроші
every actorперевіряє CanI перед дією; бачить лише свої відімкнені інтерфейси

Одним поглядом

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

Атом — це пара: ресурс і одне з чотирьох дієслів — читати, писати, виконувати, адмініструвати. Набір дієслів фіксований, і випадок, який не вміщається, розділяє ресурс, а не подовжує список. Саме тому закривати чужий вхід — це Rooms.Membership.Administer, а не власне дієслово ValidateEntry: діяти над членством іншого Actor'а — це адміністрування, тоді як увійти самому — це Rooms.Membership.Write на тому самому ресурсі.

Модель

ACL даних — це role × operation × row predicate × field mask: одна модель, однакова незалежно від того, написали її в коді, через API чи в сітці ролей у панелі.

З чого побудовано доступ.

ТермінЩо це
atomодна пара ресурс × дієслово. Чотири дієслова: read (діставати, вибирати, підписуватися), write (створювати, змінювати, видаляти і діяти за себе — увійти, вийти), execute (викликати функцію, застосувати здібність) і administer (діяти над іншими: кікнути, закрити, примусово змінити стан)
roleіменований набір атомів. Вона може включати іншу роль, а цикл у включенні — це помилка конфігурації, а не щось, що розв'язують у рантаймі
role presetпостачається над атомами і працює без змін для вже розгорнутих споживачів. Відправна точка, а не обмеження: проєкт оголошує власні ролі з тих самих атомів
row predicateякі рядки — булів предикат над значеннями сесії
field maskякі поля, оголошується на кожну роль і операцію. Поле, якого роль не має права читати, не повертають узагалі, а не повертають порожнім

У що розв'язуються облікові дані.

Облікові даніЩо вони відмикають
player keyйого несе збірка рушія; гравець за ним приходить зі входом, і збірка бачить рядки cl кожної таблиці операцій
host keyйого тримає виділений сервер або master-client, і його ролі відмикають рядки mc
pushed codeвиконується під роллю backend-service проєкту — саме це перевіряє Authoritative = true у Leaderboard
a registered hookне дає нічого зайвого: ваша функція тримає ту роль, під якою її розгорнули

Легенда тегів (fn / cl / mc / adm) належить Основним поняттям.

Що правдиве для кожної перевірки.

ЗавждиЩо це
a credentialне несуть списку ролей: вони називають особу, а ролі розв'язуються на боці сервера на кожен запит. Відкликання одразу робить недійсним закешоване розв'язання, а не чекає своєї оголошеної межі застарілості
delegationзмінює обсяг, ніколи не спроможність: дія від імені гравця змінює, які рядки видно і кому атрибутується запис, і не дає жодної операції, якої Actor і так не тримав
the verbвідповідає, якого роду ефект, а предикат відповідає, які рядки. Якщо два випадки різняться лише тим, чий це рядок, це предикат; якщо різниться сам ефект, це інша операція і, можливо, інше дієслово — і саме тому «кікнути» — це administer, а не write із широким предикатом
a hidden rowвідповідає not found: відмова не має ставати оракулом існування
an ownerзавжди бачить себе, хоч би що казав предикат
visibilityне є безпекою: оптимізація каналу і дозвіл — різні механізми, і жоден не заміняє іншого
a disabled moduleне має поверхні: чи ввімкнений модуль — це властивість збірки, тож кодогенерація нічого не генерує для вимкненого, а недоступний виклик — це помилка компіляції, а не відмова в рантаймі
a module's surfaceіде за Actor'ом, а не за стороною (Авторитетність це аргументує, а це її механізм): збірка room-visitor бачить вхід, вихід і читання, збірка room-owner бачить додатково kick, close і configure, а whoami повідомляє, які інтерфейси відмикає поточний Actor
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

Хто роздає роль. Видача й відкликання ролі гравцеві, а також оголошена проєктом типова роль для нового — це операції в Auth & Players: особи належать тому модулеві, а роль розв'язується за особою в облікових даних. Цій сторінці належить те, чим роль є; тій — те, як її передають.

Помилки

  • Те, що ховає предикат, відповідає not found, а не forbidden — інакше сама відмова каже викликачеві, що річ існує, а саме заради цього її й ховали.
  • Право, якого викликач не тримає, відповідає forbidden там, де існування суб'єкта не є таємницею, і воно називає, чого бракувало, а не відмовляє без пояснень.
  • Поле поза маскою відсутнє у відповіді, а не присутнє і порожнє: порожнє значення і замасковане не відрізнити одне від одного.
  • Роль, що включає себе, напряму чи ланцюгом, — це помилка конфігурації: її відхиляють як Declaration, а не розв'язують у рантаймі.
  • Делегування ніколи не розширює спроможності: виклик, якого Actor не міг зробити від себе, відхиляють і тоді, коли він робить його від імені гравця.

Обмеження

Кожна стеля називає свою поведінку на краю; числа надійдуть із розділом про обмеження платформи.

  • Розмір вибірки під предикатом рядків обмежений, і модель ACL оголошує цю межу, а не виявляє її. Читання понад стелю отримує стільки рядків, скільки дозволяє стеля, і маркер, який каже, що його обрізали, а ніколи не мовчазну коротку сторінку.
  • Межа застарілості розв'язаного дозволу оголошена, і відкликання її не вичікує — воно робить його недійсним одразу.

Шлях користувача

Один ключ організатора турніру, від складання ролі до живої зміни дозволу.

How the SDK works

Як влаштований SDK

Два питання плутають одне з одним, і обидва мають короткі відповіді. Як написаний SDK — чому одна й та сама ідея виглядає трохи інакше в Python і в Unreal C++. Як SDK виконується — що стоїть між вашим викликом і дротом. Ця сторінка відповідає на обидва один раз, щоб цього не довелося робити жодній сторінці модуля.

Написаний від загального до окремого

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

SDK — це один дизайн із двома звужувальними виходами, і порядок тут є правилом: ніщо не спускається рівнем нижче, доки рівень вище справді не може цього понести.

РівеньЩо тут живе
Спільні принципиОднакові в кожній прив'язці: поведінка оголошується атрибутом поруч із тим, що вона описує; кожне Declaration, яке ви відправляєте, — це Declaration, яке рендерить адмінська панель; ваш код звертається до модулів і більше ні до чого.
Форма мовиЛише те, чого парадигма мови не може виразити спільним способом. У C# є атрибути, у Python — декоратори: те саме Declaration, написане так, як кожна мова вже пише цю ідею. Мова без такої конструкції несе те саме Declaration інакше, і цей носій називається там, де він застосовується, а не припускається.
Форма рушіяЛише те, що ігровий рушій переробляє поверх своєї мови. Unreal C++ — це не звичайний C++: у нього власна об'єктна модель і власна рефлексія часу збірки, тож Declaration там живе всередині власного макроса рефлексії рушія, у тій позиції, де цей макрос уже бере специфікатори. Unity C# — теж не серверний C#: старіший рантайм, менша базова бібліотека.

Якщо читати згори вниз, це і є причина, чому шість вкладок на кожному прикладі — не шість різних API. Це одне API, написане шістьма способами, а видимі відмінності — це два нижні рівні, що просвічують крізь нього.

Як він виконується, від вашого коду вниз

Ваш ігровий код бачить модулі. Це не спрощення для документації — це весь контракт верхнього рівня.

  • Модулі — те, до чого ви звертаєтеся. Вони утворюють граф, а не дерево, і що це дає — Inheritance & Composition.
  • Чотири Primitives — те, з чого зібрані модулі: events, RPC, data and subscriptions, groups. Room, чат і пул Matchmaking — це один і той самий примітив Group під різними правилами. Якщо фічу не можна виразити через ці чотири, це дефект дизайну, а не аргумент на користь п'ятого.
  • Хаб лежить нижче, і ви ніколи його не викликаєте: впровадження залежностей, монтування модулів, сесія користувача, відновлення стану і якість обслуговування повідомлень. Він названий один раз у Під капотом.
  • Адаптери транспорту сидять на дні, по одному на протокол, і хаб ховає їх повністю. Їх буде кілька — WebSocket, наш власний UDP, HTTP, — і те, який із них несе виклик, ваш код не вирішує і не помічає.

Єдине, що модуль каже вам про доставку, — це його якість обслуговування: щонайменше один раз або щонайбільше один раз. Усе інше про те, як байти туди дісталися, навмисно не ваша справа, бо це та частина, яку ми лишаємо за собою право робити швидшою.

Що ви з цього маєте

  • Один SDK, а не клієнтський і серверний. Що виклику дозволено робити — це грант Actor'а, а не прапорець збірки. Це Авторитетність, і це найвагоміше за наслідками рішення на цій сторінці.
  • Declaration — вхід для всього. Відправте його — і з'явиться типізоване API, адмінська панель його відрендерить, а кодогенерація для кожної прив'язки піде слідом. Див. Schema as Code.
  • Модулі компонуються, а не успадковуються. Як саме і що тут чесно означає «успадкування» — це Успадкування і композиція.
How the SDK works

Потоки, час життя і тестування

Цикл ваш. Ми доставляємо рівно в одне місце і ніколи за вашою спиною. SDK не запускає жодного потоку, про який вам треба знати, не дає вам жодного замка і викликає ваш код з одного контексту, який ви обрали на старті. Викликайте нас із будь-якого потоку; ми викликаємо вас із одного.

Один контекст доставки, і цикл ваш

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

Екземпляр оголошує рівно один контекст доставки — єдине місце, де виконуються всі його обробники. Він фіксується, коли ви робите ініціалізацію, і не змінюється до кінця життя екземпляра. Event, Delta даних, результат виклику — усі вони приходять туди і більше нікуди.

Форм у нього дві, і ви обираєте одну на старті:

  • Ви його прокачуєте. Рантайм сам нічого не робить; ви вичерпуєте доставки, що чекають, зі свого власного циклу. Це та форма, якої хоче рушій, — доставки надходять на ігровий потік, у кадрі, який ви обрали.
  • Він наш. Рантайм тримає один виділений потік виконання. Це та форма, якої хоче консольний хост чи виділений сервер.

Жодна з них не є запасною для іншої, і третього варіанта з пулом потоків немає. Уся суть обіцянки про один контекст у тому, що вам ніколи не доводиться питати, скільки потоків ми зробили.

Контекст ніколи не є аргументом. Жоден обробник не бере параметра «на якому я потоці», і немає чого запитувати. Де виконується ваш обробник — це властивість контракту, а не дані виклику.

Запуск і зупинка явні

Ініціалізація — це виклик, який робите ви, і він відповідає результатом. Ніщо не ініціалізується ліниво під час першого використання — це заборонено, а не просто небажано, і причина варта речення: лінивий старт переносить єдине місце, де видно вимкнений модуль, у той довільний виклик, який трапився першим, де це читається як збій того виклику.

Вимкнений модуль називають на старті, і результат каже, яка з двох речей сталася: увесь залежний ланцюг вимкнено або ви працюєте з меншим плюс список того, що недоступне. Мовчазного третього випадку немає.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Вимкнення нічого не скасовує. Це те єдине місце, де звичка з більшості SDK у нас активно хибна. Вимкнення явне, повне та ідемпотентне — після його успіху жоден обробник цього екземпляра більше не викликається, — але про роботу, яка вже в польоті, воно не каже нічого. Операція, яку ви почали до вимкнення, лишається доступною для виявлення тими засобами, які ця операція назвала. Якщо вам треба знати, чи пройшла покупка, вимкнення — не спосіб це з'ясувати.

У кожного хендла один оголошений кінець — і це ніколи не збирач сміття

Підписка, дескриптор відкладеної роботи, сесія: кожне з них — хендл, і в кожного рівно один кінець, який називає контракт. Звільнення ідемпотентне, тож звільнити двічі — не помилка.

Три наслідки, у яких легко помилитися:

  • Кінець ніколи не є фіналізатором, деструктором чи областю видимості. Хендл, який ви покинули, лишається відкритим. Це баг у вашому коді, а не щось, що ми тихо забираємо назад, бо час життя, залежний від мови, був би іншим часом життя в кожній прив'язці.
  • Використання хендла після його кінця — це оголошена відмова, з кодом. Не порожній результат, не невизначена поведінка і не загальна помилка про звільнений об'єкт, яка не несе нічого, з чим можна щось зробити.
  • Обірваний зв'язок не є кінцем хендла. Підписка переживає від'єднання і далі отримує після перепідключення. Хендли закінчуються з причин, які називає контракт, і втрата мережі не належить до них.

Жоден хендл не переживає екземпляра, що його видав: щойно ви виконуєте вимкнення, кожен хендл, який він вам дав, — у своєму кінці.

Викликати з обробника можна; чекати всередині нього — ні

Викликайте поверхню з будь-якого зі своїх потоків. Кожен хендл вільний щодо потоків, і це обіцянка, а не властивість сьогоднішньої збірки. Ви ніколи не візьмете нашого замка, не чекатимете на нашому бар'єрі, і вам ніколи не скажуть викликати щось «під замком» — жоден примітив синхронізації взагалі не є частиною поверхні.

Обробники одного екземпляра серіалізовані: два ніколи не виконуються одночасно, а порядок усередині одного потоку зберігається. Тож обробникові не потрібно власного блокування.

Серіалізовано не означає дедупліковано. Порядок — це одна обіцянка; скільки разів доставлять повідомлення — інша, оголошена на типі повідомлення. За «щонайменше один раз» ви побачите те саме повідомлення двічі, а ключ дедуплікації, який завжди йде разом із ним, — це те, чим ви це відрізните.

Почати операцію зсередини обробника законно і не може призвести до дедлоку. Але її результат ніколи не приходить усередину того самого обробника — він повертається окремою доставкою на тому самому контексті. Іти всередину можна; розвертатися всередині — ні.

Блокувати контекст доставки заборонено, і ця заборона не є порадою. Чекати на мережі, чекати на чужому замку, синхронно чекати на власний виклик — усе заборонено всередині обробника. Заборона має симптом: обробник, що тримає контекст понад свій оголошений бюджет, дає або оголошену деградацію доставки, або оголошену відмову. Чого він не дає ніколи — це мовчазного сповільнення, яке ви виявите в сесії гравця.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

«Скасувати» — це дві різні речі

Одне слово в більшості мов, дві операції тут, і різниця спостережувана:

Ви хочетеЩо це
перестати доставляти менілокальне. Завжди вдається, зокрема й із розірваним зв'язком. Звільнення підписки — це воно.
зупинити роботузапит до платформи. Ідемпотентний, і він нічого не обіцяє про те, чи робота відбулася.

Друге — це те, у чому люди помиляються. Скасувати прийняту роботу — це запит, який може не встигнути, так само як таймаут, який теж не означає «воно не застосувалося». Після скасування будь-якого роду результат початої неідемпотентної операції лишається доступним для виявлення тими засобами, які ця операція назвала.

І два збої відрізняються: скасувати виклик, який до нас не дійшов, — це локальний збій; скасувати роботу, яку ми прийняли, дає вам термінальний стан з оголошеного набору.

Те, що ви отримуєте, — це копія минулого

Значення, доставлене вашому обробникові, після цього не змінюється. Ми ніколи не роздаємо живих посилань у власний стан, тож ніщо з того, що ви тримаєте, не мутує між двома рядками вашого коду.

Тому тримати доставлене значення поза обробником безпечно — але те, що ви лишили, є спостереженням миті, а не вікном у теперішнє. Deltas можуть зливатися дорогою до вас, тож список збережених значень не є історією того, що сталося.

Ви також ніколи не володієте нашими буферами. Немає ані «взяти і повернути», ані «зібрати і надіслати»: зміна оголошеного стану і є мережевою операцією.

Тестування: in-memory реалізація, а не мок

Є повна in-memory реалізація — та сама поверхня, той самий набір оголошених результатів, без мережі. Це окрема річ, від якої ви залежите, а не прапорець на бойовому рантаймі.

  • Вона не часткова. Операції, якої вона не підтримує, відмовляють з оголошеним кодом, а ніколи не відповідають вигаданим успіхом. Тест, що проходить на ній, проходить не просто так.
  • Час ваш. Оголошених строків — час життя дескриптора роботи, бронь, вікно утримання — досягають, просуваючи крок, а не сплячи.
  • Детермінізм оголошений і обмежений: порядок усередині потоку, оголошений режим доставки, керований час. Детермінізму з рухомою комою не обіцяють, тож повна симуляція тут теж не відтворюється.

Відмінність від мока і є суттю. Мок перевіряє, що ви викликали те, що збиралися. Це перевіряє, що викликане має сенс.

Що ви встановлюєте і нижня межа версії

Ядро — це одна одиниця; необов'язкові модулі — окремі, кожен з оголошеним складом і оголошеним списком обов'язкових залежностей. Додавання одиниці ніколи не змінює поверхні іншої: модуль монтується там, де каже його Declaration, тож ніщо не з'являється і не зникає деінде через те, що ви встановили поруч.

Якщо на необов'язкову одиницю є посилання, а завантажитися вона не може, це оголошений результат ініціалізації, у тому самому місці, де повідомляють про вимкнений модуль. Ніколи не заглушка, що тихо нічого не робить.

Кожна прив'язка оголошує мінімальну версію рантайму, під яку її зібрано. Нижче за неї ви дістаєте відмову на ініціалізації, а не часткову роботу: застарий рантайм інакше ламається на першій спроможності, якої йому бракує, а це десь довільно у вашому коді і зазвичай на машині гравця, а не на вашій. Підняття цього мінімуму є ламкою зміною і проходить той самий процес, що й будь-яка інша.

Куди далі

Building blocks

Core

Core — це єдиний об'єкт, який ви створюєте, і все інше висить на ньому. Один ключ на вході — і у вас є контекст, особа, типізовані відмови, трасування, батчинг. Кожен виклик модуля проходить крізь нього, і жоден модуль не постачає власної версії.

Who addresses Core, the 4 things it provides, and the 1 module it builds on

Коли застосовувати

  • Вам треба знати, хто ви і де ви, — особа, ролі, розблоковані модулі, project · env · region, усе на одному об'єкті, який ви тримаєте.
  • Хмарна функція мусить писати як гравець — запис атрибутується тому гравцеві, а зафіксовано обидві сторони: і функцію, і гравця.
  • Повтори ніколи не повинні застосовуватися двічі — пакетні операції несуть ключ ідемпотентності.
  • Відмова має бути такою, щоб на ній можна було розгалузитися і щоб її можна було знайти пошуком, — кожне кидання — це типізований Problem зі стабільним кодом.
  • Не потрібно, коли вам треба повідомлення, виклики чи стан — це Primitives: Events, RPC, Data.

Хто що робить

ActorНа цій сторінці
any actorчитає особу, контекст, ролі через Whoami
backend-serviceдіє як гравець; батчить ідемпотентні операції
operatorчитає трасування для викликів, що впали або були повторені

Одним поглядом

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

Особа живе всередині самого Core. Жоден параметр session чи ctx ніколи не з'являється у виклику.

Модель

Що несе кожна помилка.

ПолеЩо це
codeмашинозчитуване ім'я відмови, і воно стабільне. Словник — це проєкція наявних кодів платформи: новий код для відмови, яку платформа вже називає, заборонений
categoryклас, до якого належить відмова, — саме він каже, чи взагалі має сенс повтор
trace identifierідентифікатор саме цього випадку, присутній завжди, включно з локальними помилками, тож звернення до підтримки ніколи не вимагає спершу відтворювати збій
explanationлюдський текст, щоб його читала людина, і він не стабільний: заголовки й пояснення змінюються і локалізуються будь-коли
per-field errorsсписок, який несе валідаційна відмова, — поле, код, повідомлення на кожне відхилене поле

Споживач розгалужується на коді й категорії, ніколи на людському тексті — ні порівнянням, ні підрядком, ні розбором. Помилка, з якої досяжне лише повідомлення, — це дефект прив'язки, а не форма, яку треба обходити.

Три походження, і це не одне й те саме.

ПоходженняЩо сталося
platformвона відповіла відмовою, несучи код із каталогу платформи
localSDK відмовив до відправлення, з власного опублікованого словника
unknownвиклик було надіслано, а відповідь не прийшла. Ані «платформа сказала ні», ані «ми так і не спитали»

Що правдиве для кожної відмови.

ЗавждиЩо це
a refused operation applied nothingатомарність — обов'язок платформи, а не ваш: жодного компенсаційного читання на звичайній гілці помилки. Винятків рівно два, і про обидва сказано там, де вони виникають, — таймаут, результат якого невідомий, і батч із поелементною семантикою
the delivery pathне змінює помилки: той самий код, категорія і походження доходять до вас незалежно від того, чи прив'язка кидає, чи повертає значення результату, чи спрацьовує зворотним викликом на підписці. Шлях, що несе менше за інший, — дефект цієї прив'язки
a timeoutне є результатом: це третє походження, наведене вище, і що з ним робити, оголошують для кожної операції, а не вгадують

Core несе контекст, а не повідомлення. Випускати факти і підписуватися на них — це примітив Events; виклики — запит/відповідь, односторонній, розсилка по Group — це примітив RPC; стан, підписки і потокові читання — це примітив Data, адресований через Entity. Аудиторії, на які розсилають усі три, — це четвертий примітив, Groups. Розмірні передачі (вивантаження, завантаження) виходять на поверхню у Files & UGC. Семантика монтування — простори імен, відхилення колізії під час монтування — живе на Під капотом.

Помилки

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

Що бачить викликач без ролі. Рядки fn і adm відхиляються, а не деградують: гравець або клієнтська сесія, що викликає act as player, випускає трасування чи читає його, дістає Problem із кодом forbidden — облікові дані дійсні, ролі за ними не несуть такого права, і повтор виклику з тим самим ключем ідемпотентності нічого не змінює. Урізаного варіанта, який виконується з меншими правами і повертає менше, не існує.

Кожна відмова — це типізований Problem зі стабільним кодом: ті самі коди, які документує дротовий контракт, тож клієнт може на них розгалужуватися, а людина — шукати їх.

Обмеження

Ліміт застосовують на впуску. Виклик, який прийняли, вже пройшов ліміт і буде виконаний, хоч би скільки він чекав на обробку; відмова, яка падає на якийсь пізніший виклик, нічого не робить із роботою, яку вже впустили. Тож черга, яка заповнилася, — це черга, яка працює: повторити прийнятий виклик тому, що сусідньому відмовили, — це спосіб зробити роботу двічі.

Про ліміт ви дізнаєтеся, діставши відмову, і читати більше нічого. SDK не виносить на поверхню ані чинного значення, ані залишкового запасу, ані попередження про наближення, і нічого про ліміт ніколи не ставлять перед гравцем. Відмова несе все, що є:

  • категорію, а вона й каже, чи має повтор узагалі сенс
  • чий це був ліміт
  • коли дозволено повтор і за яке вікно

Розгалужуйтеся на цьому. Лічильника для опитування немає, і бюджету для показу немає.

Шлях користувача

Один виклик, що падає, від кидання до трасування, яке читає оператор.

Building blocks

Events

Event — це факт, що щось сталося, доставлений усім, хто має це почути. Беріть його для того, що стається один раз і чого не можна надолужити з поточного значення: постріл, покупка, вхід у Room'у.

Who addresses Events, the 5 things it provides, and the 1 module it builds on

Коли застосовувати

  • Щось сталося, і інші мають зреагувати — постріл, замкнені двері, завершений матч.
  • Аудиторія різна — той самий випуск доходить до загону, до Room'и чи до одного Actor'а, і вирішує це ціль, яку оголошує тип.
  • Вам потрібні типізовані обробники з автодоповненням — оголошений Event стає send. і on. на своїй поверхні, кожен зі своїм контрактом.
  • Факт має лишатися доступним для читання і за годину — оголосіть тип утримуваним і читайте його назад за періодом.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує Events через [Event], відправляє схему
any actorвипускає через send., підписується через on.

Одним поглядом

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

Event, оголошений усередині модуля чи Group, виходить на поверхню лише там: squad.send.rallyCall існує, бо rally_call оголошено для загонів, і випуск доходить до учасників загону. Event, який модуль випускає назовні, є частиною його оголошеного контракту; викликачі ніколи не дізнаються про неоголошені.

Модель

Що оголошує тип Event'а.

ОголошуєЩо це
nameявне дротове ім'я, оголошене, а не виведене із символу
payloadсхема того, що несе випуск
targetкуди йдуть випуски цього типу: екземпляр Entity, Group, Room або глобальний контекст. Ціль-Group — це масова доставка: один сигнал, багато отримувачів. Змінного адресата виражають Group, ніколи адресою, переданою на випуску
clocksim_time або timestamp, ніколи обидва: sim_time для фактів усередині симуляції, які беруть участь у передбаченні, компенсації лагу і відкоті; timestamp для фактів поза нею, як-от покупка чи вхід
retentiontransient — доходить до тих, хто підписаний на мить випуску, і не зберігається; або retained — зберігається і читається назад за типом і періодом, а не через поверхню запитів, яку несе Data. Оголошується, ніколи не виводиться з роду події
termна типі retained: скільки його тримають і що стається на спливі. «Назавжди» не входить до значень
deliveryщонайбільше один раз, щонайменше один раз або рівно один раз — оголошується на типі, тож підписникові ніколи не треба питати, який з них ужив випуск; «рівно один раз» називає межі, всередині яких воно тримається
contextконтекст, у якому оголошено тип, глобальний чи локальний. Глобально оголошене ім'я видно в локальних контекстах; локально оголошене не видно вище. Те, що модуль випускає, є його контрактом у будь-якому разі — про неоголошений Event викликач ніколи не дізнається

Що несе випуск.

ПолеЩо це
typeоголошений Event. Два випуски ніколи не зливаються: два постріли — це два Events, і другий не поглинає першого, а саме це відділяє Event від поля [Sync], яке несе Data
payloadвідповідний схемі типу
sourceActor, що випустив, плюс його екземпляр, коли випустила Entity. Event, випущений клієнтом, — це заява, а не факт: авторитетна сторона перевіряє його до того, як щось від нього залежатиме
stampза оголошеним годинником типу
dedup keyприсутній за будь-якого режиму доставки, бо передоставка можлива в усіх: дублікат транспорту, друге читання утримуваної події
cause keyна Event'і, який платформа випускає через інший платформний Event: id того, з чого він випливає, тож ланцюг відбудовують за ключем, а ніколи не порівнянням штампів

Що тримає підписка.

ТримаєЩо це
eventоголошений тип, до якого вона прив'язана
surfaceвузол, на якому її взято, всередині оголошеної типом target, — підписникова половина аудиторії
handlerтипізований під payload
positionзвідки вона відновлюється, оголошена, тож перепідключення не перезапускається мовчки з «тепер». Пропущене в проміжку не відтворюють повторно: transient-подія невідновна, і читати назад можна лише retained

Що правдиве для кожного Event'а, хоч би що оголошував тип.

ЗавждиЩо це
audienceїї ніколи не перелічує відправник: це оголошена типом target, звужена до тих, хто підписаний, і далі закрита Access — публікувати й підписуватися — це окремі права, і жодне не тягне за собою іншого, а потік може бути закритий предикатом навіть там, де сам тип видимий. Відправникові, який міг би перелічити отримувачів, довелося б відтворювати те, що Groups і Data уже знають
phasesвипущено, потім доставлено — і більше нічого. У Event'а немає машини станів: він стається один раз
orderingобіцяний усередині одного потоку, а для Events потік — це один екземпляр, що випускає: два Events від того самого екземпляра приходять у порядку випуску. Між потоками порядок не обіцяють у жодній формі — ані між двома екземплярами, ані між Delta і Event'ом про ту саму зміну
crossing streamsколи потрібен порядок між потоками, механізм оголошують, ніколи не припускають: зведіть повідомлення в один потік або несіть причинний штамп у payload'і
gap detectionтам, де режим допускає втрату, підписник дізнається про проміжок, а не пропускає його мовчки

Чи зберігають факт після доставки — це слот у його Declaration, а не рішення, ухвалене на випуску, тож той самий тип завжди тримають однаково, і жодному викликачеві не треба пам'ятати, який виклик був який.

TransientRetained
Доходить дотих, хто підписаний тієї митідо них, а також до підписника, що прийшов пізніше
Після цьогозникаєтримається оголошений термін
Читається назаднітак, протягом терміну
Після терміну—вибірка відмовляє, а не відповідає порожнім
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

Помилки

  • Публікувати й підписуватися — це окремі права, і жодне не тягне за собою іншого. Підписка без права відповідає forbidden, а не not-found: тип є в оголошеному контракті модуля, тож ховати нема чого.
  • Підписка на тип, якого модуль не оголошував, — це помилка контракту, яка виходить на поверхню типізованим Problem, а ніколи мовчазною бездією.
  • На випуску — три валідаційні відмови: неоголошений тип, payload, що не проходить схему, і ціль, якої тип не дозволяє.
  • Вибірка за межами терміну утримуваного типу відмовляє, а не відповідає порожньою сторінкою.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Розмір payload'а — понад стелю публікація падає, і Event не стається, ніколи не обрізаний payload.
  • Частота публікації на джерело — відмова рейт-ліміту, що несе мить повтору.
  • Підписки на Actor'а — нову відхиляють, наявні тримають.
  • Обсяг утримання на тип — витіснення за оголошеною політикою, за терміном, ніколи навмання.

Шлях користувача

Один клич, від Declaration до маркера, який кожен учасник загону бачить на власному екрані.

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

RPC

Типізований виклик, чиє тіло живе десь-інде. RPC — це другий примітив: оголосіть процедуру там, де їй належить — на модулі або всередині Entity, — і кожна прив'язка дістане згенерований метод, на який можна чекати. Дієслово — invoke: односторонність — це режим, що його називає Declaration, а не друге дієслово, і do не існує.

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

Коли застосовувати

  • Викликачеві потрібна відповідь — запит/відповідь із типізованим поверненням.
  • Викликач звітує і йде далі — оголошений односторонній RPC, назад нічого не передається.
  • Робота переживає виклик — оголошений відкладений RPC віддає дескриптор роботи замість таймауту.
  • Одне питання, багато відповідачів — виклик по Group — це N викликів, і кожна відповідь надходить прив'язаною до учасника, який її надіслав.
  • Дієслово належить речі — оголосіть його всередині Entity; RPC однієї Entity не живе більше ніде (Entity показує Declaration).
  • Не потрібно, коли нікого не просять діяти: факт, на який інші лише реагують, — це event.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує RPC, їхні режими і хто має право їх викликати
any actorробить виклик із відповіддю або односторонній там, де Declaration це дозволяє
group memberвідповідає на виклик, що розходиться на всіх; назад передається одна відповідь на учасника

Одним поглядом

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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);

Де виконується тіло — хмарна функція, клієнт, master-client чи ігровий сервер — це маршрутизація, оголошена на кожен метод; перевизначення й проміжні шари покриває Extensibility. Виклик, що впав, кидає типізований Problem (Core).

Модель

Що оголошує RPC.

ОголошуєЩо це
nameзі словника дієслів
inputаргументи, які викликач мусить обрати
outputрівно один оголошений тип. Коротша відповідь — це власний оголошений тип, ніколи не той самий тип із тихо пропущеними полями, бо інакше «не просили», «об'єкта немає» і «сховано маскою доступу» стають однією відсутністю, і їх уже не відрізнити
reply modewith a reply — значення оголошеного вихідного типу або типізована відмова; або one-way — без відповіді, і викликач дізнається лише про локальний збій відправлення. Односторонній не можна використовувати там, де викликачеві потрібен результат: невідомий результат коштує більше за відому відмову
execution modeimmediate — результат повертається всередині виклику; або deferred — виклик повертає дескриптор роботи, а результат читають або він надходить підпискою. Оголошується, ніколи не обирається реалізацією за навантаженням, бо викликач будує свою поведінку на формі відповіді
streamingчи приходять вхід і вихід частинами і чи обробляють їх у міру приходу, а не цілком
idempotencyодносторонній RPC теж несе ключ ідемпотентності: відсутність відповіді не означає відсутності передоставки
overridabilityоголошується на самому методі. Відсутність Declaration означає, що метод не можна перевизначити: типово перевизначуваних не буває
contextде його оголошено. RPC, оголошений усередині Entity, є частиною цієї Entity і поза нею не існує. Оголошення його в ігровому сервері і є реєстрацією в маршрутизаторі — другого способу додати його немає

Що несе виклик.

НесеЩо це
argumentsлише те, що викликач мусить обрати
implicit contextотримувач, викликач і навколишній контекст, зв'язані ще до вашого першого написаного параметра — у методу Entity ніколи не просять ідентифікатора цієї Entity
referencesаргумент, який є об'єктом SDK, передається типізованим Ref — ідентифікатором чи курсором, ніколи копією свого вмісту. Отримувач розв'язує його від свого імені, під тими самими дозволами й предикатами: посилання — це адреса, а не виданий дозвіл
outcomeзначення оголошеного вихідного типу або типізований Problem

Що тримає дескриптор відкладеного виклику.

ТримаєЩо це
stateaccepted → running → completed або failed, останні два термінальні
lifetimeоголошений; після нього результат недоступний, і питати про нього — це відмова, а не порожня відповідь
cancelідемпотентне і чесне: воно просить, а термінальний стан, який ви спостерігаєте, — це той із completed чи failed, до якого дійшла робота

Що правдиве для кожного RPC.

ЗавждиЩо це
one handlerрівно один логічний обробник, і саме це відділяє RPC від Event'а, де їх може не бути жодного. Тож звернення до Group — це N викликів, а не один: Groups дають адреси, а відповіді повертаються потоком, кожна прив'язана до учасника, який її надіслав
meaningпрохання виконати дію, тоді як Event — це твердження про факт. Односторонній RPC і Event ззовні схожі й не є тим самим: обробник RPC зобов'язаний існувати, а в Event'а може не бути жодного отримувача, і це нормально
no state machineу Declaration її немає, і в негайного виклику теж — він або повернув результат, або ні, а далі діють правила таймауту. Спостережувані стани має лише відкладений виклик
a streamне атомарний: потоковий вихід нічого не обіцяє про ціле — отримувач мусить бути готовий до переривання і вміти відрізнити «потік завершився» від «потік перервали»
no predicate on a writeжоден запис не приймає предиката на вхід: «зроби це всім, хто підпадає під цю умову» не є операцією. Масову дію виражають перелічуванням: прочитайте набір, віддайте список пакетній операції з оголошеною семантикою часткової відмови. На вході запису предикат обчислюють у момент, якого ніхто не називав, над набором, якого ніхто не бачив

Кожен RPC доходить до свого обробника через маршрутизатор, і те, який із його напрямків відповідає, оголошується на кожен метод, а не є властивістю місця виклику — див. Extensibility.

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

Помилки

  • Право лежить на RPC, ніколи на примітиві. Загального «можна викликати» немає: кожне Declaration називає атом, що його мусить тримати викликач, а викликач без нього дістає типізовану заборонну відмову, з кодом і всім іншим, а не мовчазне зникнення.
  • Прихований екземпляр читається як «не знайдено». RPC Entity, викликаний на екземплярі, що його ховає предикат рядків викликача, відповідає рівно так само, як відповіло б читання цього екземпляра, тож відмова не каже викликачеві нічого про те, що існує.
  • Відсутність обробника — це «недоступно», а не «не знайдено». RPC оголошений, отже, він існує; бракує маршруту. Цю відмову можна повторити — ігровий сервер може повернутися, — тоді як «не знайдено» сказало б викликачеві припинити спроби.
  • Відмова Hook'а несе власний код і причину Hook'а, тож «відхилено правилом гри» ніколи не надходить у вигляді «зламався транспорт».
  • Таймаут не є результатом. Для відкладеного виклику ви читаєте дескриптор; для негайного оголошений ключ ідемпотентності і є тим, що робить повтор безпечним, — включно з одностороннім викликом, де відсутність відповіді не є відсутністю передоставки.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Розмір входу — виклик відхиляють до виконання.
  • Розмір виходу — відхиляють, а не обрізають, бо підрізану відповідь не відрізнити від повної.
  • Частота викликів на Actor'а — відмова рейт-ліміту, що називає, коли повторити.
  • Одночасні відкладені виклики на Actor'а — новий відхиляють, а ті, що в польоті, добігають.
  • Час життя дескриптора — після нього результат недоступний, і це відмова.
  • Глибина ланцюга викликів — оголошена відмова у разі перевищення, ніколи не вичерпані ресурси й не мовчазний обрив.

Шлях користувача

Один надісланий рахунок, один пінг, про який відзвітували, один загін, у якого спитали, чи він готовий.

Building blocks

Data

Ви змінюєте одне поле. Усе, що далі по ланцюжку, стається без жодного рядка коду. Data — це третій примітив: механіка під кожним синхронізованим полем — Deltas відносно останнього підтвердженого стану, аспект як одиниця політики, пріоритет і частота надсилання, відновлювані підписки, утримуване вікно і Hooks до і після зміни.

Ви звертаєтеся до entities, а не до таблиць — поверхню читання і зміни (знайти, відфільтрувати, відсортувати, посторінкувати, підписатися на вибірку) див. в Entity; ця сторінка — про механіку під нею. Шляху споживача до таблиці немає, як немає й другого способу писати: зміна — це операція Entity, а Delta — те, що з неї випливає.

Who addresses Data, the 5 things it provides, and the 1 module it builds on

Коли застосовувати

  • Вам потрібно, щоб стан реплікувався клієнтам без коду знімків — зміна поля і є всією синхронізацією.
  • Поля різняться терміновістю чи аудиторією — пріоритет і стеля частоти надсилання на аспект, плюс предикат видимості для туману війни.
  • Клієнт, що перепідключається, не має розходитися мовчки — проміжок виявляють і називають, а на проміжок поза утримуваним вікном відповідають повним станом.
  • Вам потрібне недавнє минуле — утримуване вікно Deltas, індексоване за sim_time, і є тим, що читають передбачення й компенсація лагу.
  • Правило валідації має жити в одному місці — Hook до зміни затискає або накладає вето до того, як зміна застосується.
  • Регулятори не потрібні, коли вам треба лише прочитати чи запитати — поверхня Entity спирається на цю механіку, не торкаючись її.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує аспекти, їхню політику синхронізації і предикат видимості
any actorпідписується на ціль; відновлюється з позиції; просить повний стан
backend-serviceHooks до і після зміни
operatorчитає вартість пакета на кожного Actor'а; бачить, коли доставка деградує або пакет обрізають

Одним поглядом

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

Модель

Що несе Delta.

ПолеЩо це
changed fieldsлише вони, ніколи весь об'єкт
pairпара екземпляр × аспект, якій вона належить
numberномер послідовності всередині цієї пари, і саме він робить проміжок помітним

Що оголошує аспект.

Усе це атрибутом, на аспекті чи на окремому полі, ніколи викликом у рантаймі. Аспект задає типове значення, а поле може його перевизначити; аспект лишається одиницею політики, бо інакше не буде з чого збирати пресети.

ОголошуєЗначення і чим воно не є
priorityупорядковує, що надсилають першим, коли каналу не вистачає. Не обіцянка затримки: він відносний і впорядковує надсилання між полями, а не гарантує строк доставки
max update rateверхня межа на надсилання. Не обіцянка отримувати з такою частотою — отримання залежить від каналу
delta onlyне надсилати того, що не змінилося
delivery modeshared packet — те саме всім, дешево для CPU; або per-actor packet — кожному своє за його зоною видимості, дорого для CPU і необхідно за великого населення
visibility ruleпредикат, що вирішує, хто взагалі отримує, — Visibility проєктує цю половину повністю
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

Що тримає підписка.

ТримаєЩо це
targetекземпляр, вибірку або аспект, і вона отримує Deltas цієї цілі. Ціль — не потік: одна ціль може покривати багато пар, а порядок обіцяють усередині пари, а не в межах усієї цілі
positionзвідки вона відновлюється: її пред'являє споживач. Якщо проміжок більший за утримуване вікно, замість потоку Deltas надходить повний стан, тож довге від'єднання ніколи не лишає клієнта мовчки хибним
stateactive → gap detected → resynchronised | closed, і closed термінальний

Що правдиве для кожного потоку.

ЗавждиЩо це
mergingDeltas його допускають: 100 → 90 → 80 між надсиланнями може надійти як 100 → 80, бо кінцевий стан усе одно правильний. Саме це відділяє Delta від Event'а, де втрата одного втрачає інформацію назавжди
gap detectionмовчки втратити Delta заборонено; номер послідовності в парі — це те, що рахує споживач
orderingтримається всередині однієї пари екземпляр × аспект; між парами його не обіцяють у жодній формі
traversalлише по оголошеному: тим, що може бути фільтром, сортуванням чи включенням, є оголошене поле і оголошене посилання. Поверхня вибірки entity — це проєкція тієї моделі, а цей примітив не дає споживачеві власного обходу: другої мови запитів немає
historyбудується з Deltas: миттєве вікно Entity — це утримуване вікно Deltas, індексоване за sim_time. Його глибина — ліміт цього примітива, і він не обіцяє відтворюваності для полів з рухомою комою
the packet budgetдеградує як оголошено: коли бюджет на кожного Actor'а вичерпується, платформа відкочується до спільного пакета як оголошено, а не починає губити отримувачів навмання

Що Hook може робити і коли.

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

HookЩо він може
до змінизмінити її або накласти вето. Зміна з вето не дає жодної Delta — підписники не бачать нічого, а не значення, а потім виправлення
після змінидодати побічні ефекти, і він ніколи не може завалити зміну

Видалення чіпляють в Entity, де живе видалення; цей примітив чіпляє зміну.

Помилки

  • Неоголошена ціль підписки — валідаційна відмова.
  • Немає дозволу підписатися — відповідь forbidden або not found залежно від того, чи є саме існування цілі таємницею: відмова не має ставати оракулом.
  • Позиція відновлення, яка не розбирається, — це поганий запит, ніколи не мовчазний перезапуск з цього моменту.
  • Підписка, закрита з боку платформи, — це конфлікт, і він спостережуваний: closed у машині термінальний, і клієнтові не треба здогадуватися, що його досягли.
  • Вичерпана кількість підписок — це конфлікт: дозвіл у вас є, місця немає.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Вікно утримання Deltas — відновлення зі старішого за вікно дає повний стан, а не відмову.
  • Підписки на Actor'а — нову відхиляють, наявні тривають.
  • Розмір Delta — Delta розділяється, а не обрізається, і розділення спостережуване.
  • Частота надсилання — верхня межа, а не гарантія.
  • Вартість пакета на кожного Actor'а — на вичерпанні оголошена деградація до спільного пакета.

Шлях користувача

Одна зміна позиції, від присвоєння до виправленого руху на кожному екрані.

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Groups

Один список, один слухач на всіх. Group — це четвертий примітив: іменований набір Actors, який отримує як одне ціле. Ви звертаєтеся до Group — і чує кожен учасник. Room, чат, пул Matchmaking і список розсилки — це той самий примітив із різними правилами: інша логіка входу і виходу, інший час життя, той самий список під ними.

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

Коли застосовувати

  • Вам потрібні паті, загони чи гільдії — іменовані набори гравців з оголошеною місткістю і, де тип його оголошує, часом життя.
  • Членство має визначатися оголошеним правилом, яке обчислює платформа, — нові ветерани потрапляють туди без cron-задачі й без вашого власного виклику переобчислення.
  • Ви хочете звернутися до багатьох гравців одразу: оголошений Event розходиться через send.*, оголошений RPC доходить до кожного учасника, і кожна відповідь повертається іменованою.
  • Вам потрібна одна модель членства, перевикористана як аудиторія, — область visibility, розмова messaging, паті matchmaking.
  • Створювати не потрібно, коли набір — це учасники однієї сесії: rooms — це той самий примітив із правилами Room'и, і він уже до них звертається.

Хто що робить

ActorНа цій сторінці
playerстворює Groups з оголошених типів, входить і виходить, додає чи вилучає учасників, надсилає Events, викликає RPC на всіх учасників; тримаючи право адміністрування членства Group, вилучає учасників і закриває її
room-ownerправила місць у Room'і спираються на цей примітив (налаштовуються в Rooms)
backend-serviceоголошує типи Groups та їхні правила; Hooks на вході й виході

Одним поглядом

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

Чат Room'и — це той самий примітив із семантикою повідомлень поверх: Room оголошує власний тип Group, монтує його у простір імен Room'и і дає власному членству Room'и вирішувати, хто всередині, — тож список чату і список Room'и ніколи не можуть розійтися.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

У примітиві немає нічого про чат. Аудиторія і поверхня send.* походять звідси; автор, тред та історія — з Messaging, а хто може адмініструвати членство — це власне право (Access), яке тримає Room.

Модель

Що оголошує тип Group.

ОголошуєЩо це
nameвласне ім'я типу
membership modeexplicit — учасника додають і вилучають дією; або dynamic — членство виводять із правила, і учасником є той, хто задовольняє предикат. Group буває однією з двох, ніколи обома
ruleдля динамічної Group: предикат, тією самою мовою предикатів, що й предикати доступу та охоронці переходів. Його переобчислює платформа; ніхто нічого не опитує
capacityі поведінка на її досягненні
entry ruleпредикат, який може відхилити вхід, окремо від Hook'а, який теж може його відхилити
lifecycle behaviourна першому вході — created on first entry або created in advance; і на останньому виході — closed on last exit або kept while empty. Оголошується, ніколи не виводиться зі спостереження
lifetimeза бажанням: щойно він спливає, Group закривається з Event'ом

Що правдиве для кожної Group.

ЗавждиЩо це
memberActor, ніколи Entity: набір entities — це вибірка над Data. Group — це один слухач на всіх
statescreated → active → closed, і closed термінальний. Машина є в екземпляра Group; тип її не оголошує
event targetвипустіть на ній — і її учасники отримають; саме це робить масову доставку одним сигналом, а не циклом
a group callце N викликів, а не один: Group дає адресацію, і кожен результат надходить прив'язаним до учасника, від якого прийшов. RPC вимагає рівно одного логічного обробника, тож розсилка, що чекає багатьох відповідей, — це N викликів, а не один
partial outcomeніколи не читається як повний: учасник, у якого впало, вийшов час чи який відмовив, — це власна відповідь, що несе свій Problem поруч із тими, хто відповів; часткового успіху ніколи не повертають як повного
recipientsніколи не перелічує відправник: вирішує членство, тож відправникові не треба знати складу аудиторії
intra-group rolesне існують: «власник Group» — це Actor, що тримає право (Access), а не звання, збережене у списку учасників
first entry and last exitвідрізняються від входів і виходів між ними, і саме на цьому тримаються оголошена поведінка життєвого циклу й ініціалізація раунду
recomputationнесе Delta: подія складу Group, оголошеної правилом, каже, хто зайшов і хто випав, ніколи не весь список. Весь список — це читання, тож підписник, якому потрібна лише зміна, ніколи не платить за список
join and leaveідемпотентні: клієнт, що перепідключається, повторює свій вхід, дістає те саме членство і жодної помилки — клієнтському коду ніколи не треба відрізняти «я вже всередині» від «мені не можна всередину»
the interfaceналежить конкретній Group, а не лише типові: ви звертаєтеся до цього загону
the primitiveлишається порожнім: правила входу, ініціалізація раунду на першому учасникові, перехоплення Events — це модулі, побудовані на ньому. Room — це Group із правилами місць, розмова messaging — Group із правилами доставки, пул matchmaking — Group, яку вичерпує матчер, а список розсилки — Group без правил узагалі

Помилки

  • Правило, що каже ні, і Hook, що каже ні, — це різні відповіді. Хибне правило входу читається як «вхід неможливий»; відмова Hook'а несе власну причину і код Hook'а. Вхідний Hook, до якого не можна достукатися, відмовляє у вході: перевірка падає закритою, а не пропускає Actor'а далі.
  • Динамічне членство не приймає ручних правок. Add чи Remove на Group, оголошеній правилом, — це валідаційна відмова: предикат — єдине, що рухає цей список, і платформа переобчислює його, коли змінюються дані за ним.
  • Чотири права, і жодне не тягне за собою іншого: увійти, адмініструвати членство, публікувати в Group, читати склад (Access). Actor, у якого бракує одного, дістає відмову, ніколи не мовчазне нічого; Group, прихована від нього предикатом видимості, натомість відповідає «не знайдено», а власне членство учасник бачить завжди, навіть коли склад для нього закритий.

Обмеження

  • Заповнено — це конфлікт, а не питання права. На місткості + 1 вхід відхиляють як конфлікт — Actor'у було можна, місцю ні, — і той самий виклик спрацьовує, щойно місце звільниться. Сама місткість — на типі (Capacity = 4 вище); скільки Groups дозволено тримати проєкту й окремому Actor'у, задають разом із розділом про обмеження платформи.
  • Завеликий виклик до Group відхиляють цілком, до того, як щось надіслали, — розсилка ніколи не доставляється наполовину, тож жодному викликачеві не треба цей випадок виявляти. Стеля розміру надійде з розділом про обмеження платформи.

Шлях користувача

Паті збирається, один клич доходить до кожного учасника, один RPC на всіх учасників приносить відповідь від кожного, і загін стає в чергу як одне ціле.

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Extensibility

Кожен сценарій платформи — це ланцюг зареєстрованих функцій. Замініть ланку або огорніть її. Саме це конкретно означає «платформа, яку можна налаштовувати», і саме це заміняє відкриті вихідники: ви заміняєте власні кроки платформи своїми, тож наші вихідники вам не потрібні.

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Крок платформи має виконати вашу логіку — оголосіть заміну іменованої ланки через [Override(…)].
  • Вам потрібні перевірки чи побічні ефекти навколо кроку — упорядковані Before/After проміжні шари, що можуть накласти вето або сповістити.
  • Код має виконуватися за розкладом, на Event чи на вебхук — тригери передають вам розібраний, типізований контекст.
  • Ви маєте знати, що саме виконуватиметься, до деплою — проженіть ланцюг «насухо» і прочитайте розв'язаний порядок.
  • Не потрібно, коли правило стосується запису в одну Entity: Hook у Data — легша форма.

Хто що робить

ActorНа цій сторінці
backend-serviceперевизначає ланки, огортає кроки проміжними шарами, пише обробники тригерів
operatorоглядає ланцюги, задає порядок, читає секрети, проганяє розв'язання насухо

Одним поглядом

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.

Модель

Що платформа дає вам, щоб до цього чіплятися.

ТермінЩо це
registered functionодин перевизначуваний крок платформи — «створити профіль», «розв'язати ціну»
scenarioупорядкований ланцюг, який виконує потік платформи: автентифікація, вхід, покупка
overridabilityчи можна ланку замінити, лише огорнути, чи вона фіксована
middlewareупорядкований обробник до/після навколо ланки
triggerте, що запускає ваш код: Event, розклад, вебхук
secretзначення, яке ваш обробник має право прочитати
invocationодин запуск, зі своїм трасуванням
A scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

Що оголошує Hook.

ОголошуєЩо це
positionіменований крок, до якого він чіпляється
kindgatekeeper — перевірка допуску чи валідація, і він у разі збою відмовляє, тож крок не виконується, коли ламається сам Hook; або observer — лог, сповіщення, лічильник, і він у разі збою пропускає, крок виконується, а про збій усе одно звітують, а не замовчують його. Типового значення немає
momentbefore — перед валідацією, отримує типізований payload і може його змінити або відхилити; або after — щойно крок закомітився, отримує запит і результат, лише побічні ефекти, і він ніколи не може зірвати операцію чи змінити відповідь
effectте, що Hook робить, а не лише те, де він сидить. Саме це перетворює «який із них виконується першим» із суперечки за числа на твердження про роботу, і саме тому впорядкування переживає те, що хтось додав Hook поруч із вашим
version and conditionобробник, що стосується одного Environment чи аудиторії, — це оголошена версія цього Hook'а, а не гілка всередині його тіла, і саме її перемикає тумблер у панелі

Три способи причепити код.

ФормаБеріть для
атрибут [Before(Step)] / [After(Step)]одного правила на одному іменованому кроці — більшість Hooks
атрибут [Override(Link)]заміни реалізації ланки цілком
Extend.Scenario("…").Before("link", fn, order: n)огортання ланки всередині ланцюга, коли важить порядок щодо інших проміжних шарів
ЗавждиЩо це
all threeрозгортаються через playserv push
the two attribute shapesце те, що рендерить панель, бо Declaration несе ім'я кроку чи ланки у відправлену модель
the middleware formнесе натомість порядок, а саме його й потребує ланцюг
assigning at startup(Scenario.OnX = fn) лишається доступним для обробника, якому не треба з'являтися в адмінському дереві
replacing one linkлишає ланки обабіч недоторканими, і жодна з них не знає, яка реалізація відповіла — власний крок платформи чи ваш

Куди йде виклик і що правдиве для кожного маршруту.

ЗавждиЩо це
four directionsхмарна функція · зовнішній бекенд споживача · ігровий сервер · інший оголошений
the routerкерований повідомленнями/сигналами; request-response — це один адаптер на нього, а не його природа
matchingза оголошеним іменем операції чи сигналу і більше ні за чим: ані за формою payload'а, ані за викликачем, ані за навантаженням
a name registered twiceце дефект Declaration, який відхиляють, коли набір оголошують, а не розв'язують під час виклику
an unregistered nameвідповідає «не знайдено», а не зникає мовчки
the directionне є частиною контракту операції: перенести обробник між напрямками не є зміною, що ламає сумісність
"the game server"визначається тим, чим він є, а не тим, хто його хостить: наш флот і власний хостинг студії — це один напрямок, і Declaration не несе маркера того, кому належить інфраструктура
game-server RPCsреєструються в тому самому маршрутизаторі: оголосити один — це і є його реєстрація, і другого способу немає
orderingвиконує проміжні шари згори вниз, а там, де в кроку більше однієї реалізації, маршрутизатор обирає зліва направо за умовою, і версія, позначена типовою, відповідає, коли не збіглося нічого

Чим є обмеження між Hooks і коли його перевіряють.

Що це
a named constraintточка розширення може назвати ефекти, які вона обмежує, — античітова перевірка мусить передувати розміщенню, чек потребує списання в цій точці — і не обмежувати нічого іншого
an unnamed effectнеобмежений, а не відхилений: споживач, що робить щось, чого ніхто не передбачив, — це те, заради чого механізм і існує, а закритий словник перетворив би це на відмову під час реєстрації
a violationце дефект конфігурації, і відмова називає обидва Hooks і обмеження, яке вони порушили, — не попередження і не мовчазне перевпорядкування
when it is checkedна кожному акті, що може змінити те, що виконується в точці: реєстрація, деплой, зміна розстановки. Тож розстановка, яка дійшла до виконання, уже допущена
never re-checked at run timeце була б друга відповідь на вирішене питання, поставлена тієї єдиної миті, коли з нею вже нічого не зробиш
ЗавждиЩо це
handlersтипізовані на вході й на виході: ані dynamic, ані мішків контексту. Хендл платформи навколишній, а контекст виклику — викликач, тригер, трасування — надходить розібраним
what an engine build seesEvents, які сценарій випускає після цього, бо перевизначення чи проміжний шар виконується на платформі, а рантайм рушія не місце, щоб його хостити. Саме це мають на увазі вкладки @na у прикладах цієї сторінки, коли підписуються на події, що з цього виходять
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.

Візьміть, змініть, відправте. «Пропрієтарний open source» — це робочий процес, а не гасло: власна логіка платформи — це функції, які ви можете витягти, відредагувати й передеплоїти:

playserv functions pull matchmaking.match     # реалізація платформи, вихідником
# правка: розширити вікно скіла на вихідні
playserv push                                 # реєструється ваша версія; типова лишається запасною

Налаштування тут іде по одній осі, і це логіка: overrides, middleware і версії, про які ця сторінка.

Другої осі не існує. Свої поля в Entity платформи додати не можна. Гравець, Room, запис Leaderboard і замовлення — це системний стан із власними машинами, а не початок вашої моделі даних. Ваші дані — це ваша власна Entity, оголошена в Schema as Code і прив'язана до стану платформи предикатом: власник — цей гравець, область — ця Room, — що й лишає її вашою, коли власна модель платформи зрушить.

Помилки

  • Ім'я, яке не збігається з жодним зареєстрованим обробником, відповідає not found — виклик ніколи не зникає тихо через те, що ніхто не слухав.
  • Двічі зареєстроване ім'я і набір, у якому дві реалізації претендують на ту саму умову, відхиляють, коли набір оголошують — на деплої, а не розв'язують підкиданням монети під час виклику.
  • Відмова Hook'а несе власний код і причину Hook'а, тож «правило гри сказало ні» ніколи не надходить у вигляді збою транспорту.
  • Hook, який упав, поводиться за своїм оголошеним родом — fail-open чи fail-closed, — і який саме це рід, оголосили, а не вивели з того, що сталося.
  • Hook не має права змінювати вже заявлене: ані власника, ані ціль, ані таблицю чи розмову, яким виклик було адресовано. Він виправляє входи і повертає вердикт.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Строк виконання Hook'а — після нього поведінка у разі збою, яку оголошує його рід.
  • Hooks в одній позиції і реалізації одного методу — реєстрацію ще однієї відхиляють.
  • Глибина вкладеності «Hook викликає операцію, у якої є Hooks» — оголошена відмова, ніколи не вичерпання ресурсів.
  • Розмір контексту, переданого Hook'ові — обрізання заборонене, тож натомість відхиляють реєстрацію, а не дають обробникові отримати половину контексту.

Шлях користувача

Одна покупка, від кліку гравця крізь налаштований ланцюг до пінгу в загін. fraud-check — це власний обробник студії на Before("grant"), а не модуль платформи; Commerce малює ту саму покупку зі свого боку.

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

Ланцюг із перевизначеннями і проміжними шарами можна розв'язати і прочитати до того, як щось запуститься. Розв'язаний порядок можна оглянути в панелі і з коду.

Уроки й рецепти: Leaderboard у Tanks уживає Hooks цього модуля; щоденний турнір проходить крізь цей модуль.

Your game's model

Schema as Code

Оголосіть модель у коді, відправте її, отримайте типи назад. Шлях розробника всередину схеми: адмінська панель і код пишуть ту саму модель, а кодогенерація замикає петлю для кожного рушія.

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

Коли застосовувати

  • Ваша модель даних має жити в коді й проходити рев'ю як код — оголосити, schema diff, schema push.
  • Типи рушія ніколи не повинні розходитися з розгорнутою моделлю — schema codegen перегенеровує Unreal C++ і Unity C#.
  • Ламка зміна має бути доступною для читання і скасовною до того, як вона виконається, — propose → plan → apply.
  • Ви перевикористовуєте один набір (Stat, Interactable) між проєктами — оголосіть його один раз пресетом.
  • Не потрібно, коли оператор лише підкручує значення в адмінській панелі — зміна моделі все одно звіряється з кодом.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує entities/parts/enums у коді, звіряє і відправляє
operatorпереглядає огляд у панелі, пропонує і застосовує міграції
ciконвеєр збірки, що виконується під ключем backend-service: відправляє на мерджі й перегенеровує типи рушія після цього

Одним поглядом

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # локальні Declarations проти розгорнутої схеми
playserv schema push       # з передумовою за Revision — жодних сліпих перезаписів
playserv schema codegen    # перегенерувати типи Unreal C++ / Unity C#

Push — це явний крок. Коли ви зберігаєте файл, нічого не вивантажується: Declaration доходить до розгорнутої моделі лише тоді, коли виконується playserv push (або schema push) — з вашої машини чи з CI, — і він несе ревізію, з якою його звіряли. Правку в панелі ви бачите так само, як і будь-яке інше розходження: schema diff показує її відносно ваших Declarations. Побачити її можна автоматично; перенести її в будь-який бік — це команда, яку ви запускаєте навмисно.

Модель

Що несе Declaration.

НесеЩо це
keyстабільне ім'я, за яким до нього звертаються. Перейменування в коді — це перейменування, а не видалення і створення
kindEntity, part або enum, оголошені в коді
ownership modeseed — код створює запис, якщо його немає, а повторний push лишає значення в спокої, тож далі ними володіє адмінська консоль; або managed — код володіє ним завжди, кожен push зводить значення до оголошених, а правки з адмінської консолі відхиляються, а не застосовуються, щоб зникнути на наступному push
presetза бажанням: перевикористовний набір — пресет entity, як-от stats чи world-objects, — оголошений один раз і застосовуваний як тип. Він не вводить нового роду Declaration, а пресет, якому це знадобилося б, був би прогалиною в контракті, а не більшим пресетом

Що обіцяє push.

ЗавждиЩо це
matchingза ключем, ніколи за символом: повторний push після перейменування символу лишає один запис, а не два
idempotencyвипливає звідти — повторний push не є другим записом
the reportкаже точно, що зміниться, до застосування, і що він перезаписав після нього
originрозрізнюване: запис, створений push'ем із коду, відрізняють від створеного деінде
the revisionіде разом із ним, і push застосовується цілком або не застосовується

Що обіцяє кодогенерація.

ЗавждиЩо це
regenerationвідбувається після кожного push, а згенеровані типи ніколи не правлять руками: перегенерувати і потім звірити — і змін не буде
namingіде за Declaration, хоч би де його написали: поле Rarity на Item стає UPSItem::Rarity в Unreal і Item.Rarity в Unity
the two directionsне розгалужуються: усе, оголошене в коді, з'являється в панелі, а все, що оператор написав у панелі, чисто звіряється з кодом — і саме це робить schema diff повною відповіддю, а не половиною

Що обіцяє міграція.

ЗавждиЩо це
when one is requiredзміна персистентного Declaration, що переписує наявні значення, і ламку персистентну зміну не можна опублікувати без неї
what it declaresверсію, попередній перегляд, упорядковане застосування, відкат у разі збою і спостережуваний результат завершення
coexistenceпоки живі дві версії даних, читання і записи оголошують, які версії вони приймають, — рантайм ніколи не виводить сумісність з імен полів

Помилки

  • Викликач без ролі дістає forbidden, і розгорнутої моделі не торкаються: відмова ніколи не є частковим push'ем. Це інша відмова, ніж застаріла ревізія, яка є precondition_failed і означає, що diff обчислили відносно схеми, яка відтоді зрушила, — звірте наново і відправте знову.
  • Значення поза оголошеною межею відхиляють на записі, ніколи не затискають, і рядок, довший за оголошену довжину, так само. Затискання дає значення, яке дійсне і хибне, а ціна лягає на підтримку, а не на викликача: відмова коштує одного циклу запит-відповідь.
  • Некоректну послідовність UTF-8 відхиляють на записі, а не лагодять.
  • Ламку персистентну зміну без оголошеної міграції взагалі не можна опублікувати.

Обмеження

Стелі, що мають форму Declaration, перевіряють під час оголошення — на деплої чи на публікації, — а не на першому вживанні, скрізь, де симптом у рантаймі не був би схожий на відмову. Це правило викладає розділ про обмеження платформи, і саме тому схема, яка постачається, — це схема, яка вже вміщується. Самі числа надійдуть із тим розділом.

Шлях користувача

Одне нове поле, від його Declaration у коді до перегенерованих типів рушія.

Your game's model

Entity

Модуль, на який спирається все інше. Entity — це Declaration схеми, вирощене живими аспектами: дані 0..*, стани 0..*, RPC 0..*, Events 0..*, Hooks та історія змін. Карти прив'язують перешкоди до entities, колізія прив'язує аспект трансформа, стати і є пресетом, а об'єкти світу — пресет плюс машина станів.

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity монтується в корінь, тож room.Entity<Door>(id) і playserv.Entities<KeyDef>() сидять просто на корені, а не за простором імен. Вона стоїть на трьох Primitives — events, RPC і data — і більше ні на чому. Collision, locomotion і prediction сидять над нею: кожен прив'язується до одного аспекту, а не до всієї Entity, і саме тому контакт може запустити перехід так, що модуль колізій нічого не знатиме про дозволи.

Коли застосовувати

  • Об'єктові світу потрібна поведінка, а не лише поля — машини станів, RPC із дозволами і Events на одному Declaration.
  • Двері, пастки, підбиранки: переходи мають спрацьовувати від Events клієнта, контактів collision чи порогів стат без коду Room'и.
  • Ви хочете ігрові об'єкти однорядковим створенням — застосуйте або виведіть пресети на кшталт world-objects.
  • Суперечка потребує точного стану світу на мить пострілу — прочитайте екземпляр на минулому sim_time, усередині оголошеного вікна.
  • Не потрібно, коли в речі немає особи — значення, що живе лише всередині чогось іншого, як-от напис на табличці дверей, — це поле в аспекті, а не власна Entity. Усе, до чого звертаються, є Entity: Data — це механіка під нею, і жоден шлях до таблиці її не оминає.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує entities, аспекти, машини станів, пресети
every actorзапитує, підписується, викликає RPC Entity, читає стан

Одним поглядом

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

Оголошеною одиницею є аспект, а не поле: motion несе власний ритм і власну маску, info несе інші, а поле належить рівно одному з них. Саме це дозволяє пресетові причепити цілу групу одразу і дозволяє collision прив'язатися до того одного аспекту, що несе трансформ, не бачачи на Entity нічого іншого.

Машиною в цьому блоці керують три правила:

  • Ім'я з крапкою вкладає на один рівень. open.blocked асоціюється з open сам собою, тож перехід close_requested, оголошений на open, діє всередині нього без повторення. Поки машина стоїть у open.blocked, вона є в open — перевірка стану на open істинна, а OnEntered("open") спрацював на вході й для підстану вдруге не спрацьовує.
  • Таймер, оголошений на стані, іде лише поки цей стан поточний. Вихід із opening скидає його AfterSeconds, а повторний вхід запускає свіжий.
  • Сторож — це оголошений предикат над власними полями Entity, тією самою мовою, що й предикат рядка в доступі. Логіка, якій потрібен код, — це Hook, а не сторож.

Шляхи полів — це написання відправленої моделі. Предикати й шляхи запитів називають поля так, як їх відправило Declaration — motion.jammed, gate.state, info.name, — хоч би як кожна прив'язка пише їх локально.

Хто може викликати RPC. RPC в Entity називає потрібне йому право так само, як будь-яка operation: atom права (entity × execute) плюс предикат рядка, що каже, які екземпляри він покриває (обидва належать Access & Roles).

У DeclarationЩо це означає
caller in entity.roomбудь-який Actor у Room'і, де стоять двері, з якою б збіркою він не прийшов. Близькість сюди не входить: наскільки близько треба стояти, щоб отримувати дельти дверей, — це правило Visibility на політиці синхронізації аспекта, тобто смуга, а не дозвіл, і розширення огляду ніколи не розширює права
Actorособа викликача, той самий об'єкт, що повертає whoami
caller.Inventoryхендл Inventory для цього гравця, доступний скрізь, де змонтовано цей модуль

playserv push — це те, що робить Declaration справжнім. Цей крок належить Schema as Code: він диференціює ваші Declarations відносно розгорнутої моделі, несе ревізію, відносно якої їх диференціювали, і відхиляє замість перезапису, якщо розгорнута схема зрушила. Повторний push, що зламав би вже живі екземпляри, іде через propose → plan → apply, тож план можна прочитати до того, як щось зміниться.

На клієнті Entity і є API:

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

Сигнал locked — це власний Event дверей: його ціль — ціль Declaration, цей екземпляр, — тож чують його всі, хто підписаний на двері, і жоден список отримувачів не йде разом із відправленням.

Модель

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

Танк, двері, смуга характеристики і квест — усе це entities. Вони різняться тим, які аспекти несуть, і більше нічим, і саме це дозволяє кожному іншому модулю стояти на цьому.

Що оголошує Entity.

ОголошуєЩо це
aspectіменована група полів, оголошена цілком, із власною політикою синхронізації і маскою доступу. Entity несе кілька, і жодне поле не входить у два
state machineстани, вкладені на один рівень, переходи й охоронці; кілька на Entity
triggerте, що запускає перехід, — чотири джерела нижче
entity RPCдієслово, що стирчить із Entity, оголошене всередині подання разом із потрібним йому атомом права
entity eventсигнал, який Entity випускає, доставлений тим, хто підписаний на цей екземпляр
hookдо і після, на операціях над даними і на переходах, розгортаються як хмарні функції. Порядок, форму вердикту і те, що робить збій, оголошує Extensibility
history trackчи тримає подання миттєве вікно взагалі
refзв'язок з іншою Entity, що тримає її id і ніколи її key, тож перейменування ключа ніколи не ламає зв'язку. Include втягує її разом зі сторінкою

Що запускає перехід, і жодне з чотирьох не є вашим кодом, що виконується в Room'і.

ДжерелоЯк воно спрацьовує
client event or RPCбудь-який оголошений — RequestOpen вище запускає open_requested
collisionконтакт або вхід у тригерний об'єм — пастки, натискні плити — через аспект, до якого прив'язується Collision
data thresholdоголошений на статі, 0 HP → death, забезпечений порядком Hooks, а не кодом у Room'і
timeAfterSeconds на стані — це оголошений тригер, а не корутина: він іде за симуляційним годинником Room'и, просувається разом із sim_time, стоїть, поки Room не симулює, а видалення екземпляра завершує його машини і їхні незавершені таймери разом із ним

Що дозволено вибірці.

ВісьЩо допустимо
filter і sortлише оголошені поля — хендла таблиці немає, а вибірка адресована через Entity й обмежена Room'ою або проєктом
includeоголошений ref, утягнутий разом зі сторінкою
pagingнепрозорим курсором: не зсувом, не id рядка, і його значення не переживає зміни версії. Передавайте його назад, ніколи не розбирайте
accessпредикати діють до посторінкування, тож на сторінці ніколи немає дірок там, де були б приховані рядки
liveпідписка на вибірку тримає її живою, і члени входять і виходять у міру того, як змінюються їхні дані
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

Що правдиве для кожної Entity.

ЗавждиЩо це
a selectionце набір entities, а не group: члени Group — це Actors, і вона існує, щоб один сигнал дійшов до всіх них, тоді як вибірка — це читання, яке просто лишається живим
a transition requestлишається запитом: виконуються охоронці машини, виконується предикат рядків, а перехід, якого машина не оголошує, відхиляють із invalid_state_transition, а не ігнорують тихо
pushing past a guardце інша операція з іншим атомом — entity × administer, якого жоден клієнтський ключ типово не тримає
historyце лише миттєве вікно: недавні стани, індексовані за sim_time, обмежені оголошеною глибиною, а читанню поза ним відмовляють, а не відповідають найближчим значенням. Розгалужена історія — альтернативні лінії часу, скасування, перегравання цілого матчу — поза межами, бо їй довелося б обіцяти відтворювані значення з рухомою комою, а правила типів цього не роблять
the boundary of a changeце одна Entity, і саме там закінчується «усе або нічого». Дві entities, змінені одним викликачем — списати гаманець, додати предмет, — можуть спостерігатися застосованими наполовину. Тож пара, яка мусить з'являтися разом, — це не дві entities: тримайте обидва значення в одному екземплярі, і межа зробить роботу. Тягтися по Hook, щоб «зробити це атомарним», не допомагає, бо Hook виконується навколо однієї зміни, а не поперек двох
a declared method with no implementationце завершений стан, а не наполовину налаштований. Його виклик відповідає вердиктом, що несе машинозчитувану причину «немає реалізації», — не відмовою і не успіхом із порожнім результатом. Відмова означала б, що виклик не варто було робити; тут варто було, і єдине, чого не сталося, — це рішення
a name never declaredце інший результат, ніж ім'я, оголошене без реалізації: перше — валідаційна відмова, друге — вердикт, і код може їх розрізнити
an unimplemented callне зникає: те, що хтось його викликав, спостережуване для студії. Якої форми набуває це спостереження, навмисно не є частиною контракту, тож будуйте на факті, що воно спостережуване, а не на рядку логу
creating an instanceнесе атом entity × write: хмарна функція, виділений сервер і master-client тримають його типово, а звичайний клієнт — лише там, де це дає роль, у кожній прив'язці, а не тільки в Unreal

Пресети. Пресет — це іменований набір аспектів, машин, Hooks і лімітів, застосований до подання. Він не додає нових понять: усе, що приносить пресет, ви могли б оголосити руками, і саме тому пресет, якому потрібен новий рід Declaration, є прогалиною в моделі, а не більшим пресетом. Постачається п'ять: stats, abilities, projectiles, drops, world-objects, і Entity presets оголошує кожен повністю. Студія виводить із них власні, у коді чи в панелі: Crate — це world-objects плюс stats:

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Помилки

  • Екземпляр, якого не існує, і той, якого ховає предикат, обидва відповідають not found — тож відмова ніколи не каже викликачеві, що щось існує, але не його.
  • Неоголошене поле, включно з вкладеними, і обов'язкове поле без значення — це валідаційні відмови, які називають поле.
  • Перехід, якого машина не оголошує, відхиляють як invalid_state_transition, ніколи не ігнорують тихо. Проштовхнути машину повз її охоронців — це інша операція з іншим атомом, administer, якого жоден клієнтський ключ типово не тримає.
  • Розбіжність версій — це збій передумови: перечитайте і вирішіть знову.
  • Зайнятий ключ — це конфлікт.
  • Запис від імені гравця, що не називає гравця, — це валідаційна відмова, а не запис, атрибутований нікому.
  • Немає дозволу — відповідь forbidden, і читання й запис розрізняють.
  • Читання історії поза миттєвим вікном відхиляють, а не відповідають найближчим значенням: «даних на цей Tick немає» і «ось приблизне значення» — це різні факти.
  • Збережений екземпляр понад ліміт розміру — це конфлікт, що називає поле-винуватця й виміряний розмір; стелі досягають накопиченням, тож наближення до неї спостережуване до того запису, який упаде.

Обмеження

Кожен ліміт оголошують разом із тим, що стається на його краю. Числа задають на кожен проєкт; поведінка нижче зафіксована вже зараз.

МежаНа краю
аспекти на подання · машини на подання · глибина вкладеності всередині аспектуDeclaration відхиляють на playserv push, ніколи не обрізають мовчки
розмір збереженого екземпляразапис відхиляють як конфлікт, називаючи поле і виміряний розмір; наближення до ліміту спостережуване до відмови
розмір сторінки вибіркисторінку ріжуть до стелі, а «є ще» лишається істинним — ви ніколи не дістаєте короткої сторінки, що виглядає остаточною
вікно миттєвої історіїчитанню поза вікном відмовляють, а не відповідають найближчим значенням
частота змін одного екземпляравідмова рейт-ліміту, що несе, скільки чекати

Шлях користувача

Одні двері, від їхнього Declaration до дзвіночка, який чує гравець.

Your game's model

Успадкування і композиція

Модулі стоять один на одному, і ніщо з цього не є успадкуванням класів. Немає ані базового модуля, від якого походити, ані ієрархії, яку розширювати, — модулі утворюють граф. Ця сторінка про те, що тут чесно означає «успадкування», і про шість механізмів, які роблять роботу натомість.

Що тут означає успадкування

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

Це слово покриває чотири різні механізми, і їх варто розвести по іменах.

  • RPC однієї Entity — частина цієї Entity. Вони не існують більше ніде: ні на якомусь батькові, ні у спільному реєстрі. Якщо метод належить дверям, він на дверях. Див. Entity.
  • Пресет — це іменований набір, а не базовий клас. Stat, здібності, снаряди, генератори дропу та об'єкти світу — це пресети entity, набори аспектів, які застосовує подання Entity, і саме тому вони живуть на одній сторінці як Entity Presets, а не як п'ять модулів. Застосування пресета додає аспекти; воно не ставить ваш тип ні під що.
  • Перевизначення кроку платформи — це атрибут на вашій заміні. Ви не успадковуєте наш; ви оголошуєте свій, а версії обираються за умовою, з типовим значенням платформи як запасним. Див. Extensibility.
  • Модуль позичає інший через декоратор, який звужує або збагачує позичений інтерфейс, а реалізація за ним замінна. Пророблений випадок — чат усередині Room'и, на Groups.

І чим це не є: ієрархії класів модулів не існує, бо дерево дозволяє лише гілки, а справжні фічі їх перетинають. Matchmaking резервує місця в Rooms; дроп кладе предмети через карту; Leaderboard живиться Hook'ом на закритті Room'и. Це граф, і це навмисно.

Шість механізмів

Кожен оголошується атрибутом поруч із тим, що він компонує, — те саме декларативне правило, яке керує всім іншим у SDK.

Точки монтування, як у файловій системі. Модуль монтується в корінь — складаючи кілька інтерфейсів в одну поверхню — або в простір імен. Другий модуль, що претендує на зайняту точку монтування, відхиляється під час монтування, а не на першому виклику. Механізм — на Під капотом.

Лексична видимість. Видимість імен іде за вкладеністю: глобальне оголошення видно всередині модуля, локальне ніколи не протікає вгору. Те, що модуль випускає, — окреме питання і оголошується в його власному контракті: модуль знає лише ті Events, які оголосив сам або які зареєстрували в ньому.

Інкапсуляція як контракт. Модуль ніколи не знає, хто його викликає і навіщо. Те, що він виставляє і що випускає, — уся його публічна історія, і ніщо у викликачі не змінює його поведінки, крім гранту викликача.

Перевикористання через декоратор та інверсію керування. Модуль посилається на інший через декоратор, а не лізучи всередину, і реалізація за інтерфейсом замінна. Саме цей механізм дозволяє вам замінити один із наших модулів своїм так, щоб модулі, які від нього залежать, цього не помітили.

Declarations вирощують API. Оголосіть Event на Group — і з'явиться group.Send.ChatMessage(…) зі своїм контрактом; оголосіть дані members — і з'явиться типізований геттер. Declaration і є входом кодогенерації, і саме тому ви версіонуєте Declaration, а не згенерований код.

Три осі адресації з одного модуля. Усі екземпляри, один екземпляр і адміністратор одного екземпляра — це три різні API, а не одне API з прапорцем. Повністю викладено на Groups.

Неявні аргументи і чому це не магія

Усередині Entity ви ніколи не передаєте цю Entity. Приймач, викликач і навколишній контекст зв'язуються автоматично, бо всі три вже визначені тим, звідки зроблено виклик і хто його зробив, — передавати їх означало б просити вас повторити те, що платформа вже знає, і давати вам нагоду сказати це неправильно. Механізм — на RPC.

Your game's model

Entity presets

Пресет — це іменований набір аспектів entity — дані, стани, RPC, Events, Hooks — упакований під один ігровий випадок. Ви застосовуєте пресет, підкручуєте його числа або виводите свій. Застосування додає аспекти вашому типу; воно не ставить ваш тип під щось — пресет не є модулем, і в нього немає нічого власного, від чого успадковувати. Стати, здібності, снаряди, таблиці дропу й об'єкти світу — це п'ять пресетів, а не п'ять підсистем: те саме Declaration, та сама синхронізація, той самий порядок Hooks.

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

Коли застосовувати

  • Річ у вашій грі несе числа, які затискаються, регенерують і запускають перехід на своїх межах.
  • Дії потрібні вартість, кулдаун, фази й ефекти, досяжні з одного клієнтського дієслова.
  • Щось вилітає, і його влучання має бути розсуджене чесно для стрільця з лагом.
  • Здобич має походити зі зважених шансів, які точно переграються, коли гравець сперечається про дроп.
  • На карті є меблі — двері, кнопки, пастки, об'єкти, що руйнуються, — зі станами, що мають пережити вхід посеред раунду.
  • Пресети не потрібні, коли Entity — це просто синхронізовані дані. Оголосіть поля і зупиніться.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує стати, здібності, снаряди, таблиці дропу, об'єкти світу
room-ownerпідкручує числа пресетів, кидає таблиці дропу, створює об'єкти світу
playerзастосовує здібності, стріляє, підбирає здобич, взаємодіє з об'єктами

Одним поглядом

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Модель

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

Пресет не вводить нових понять. Усе, що він додає, виражається засобами, які entity вже дає: аспекти, машини, Hooks, персистентність. Пресет, якому знадобився б новий рід Declaration, був би прогалиною в контракті, а не приводом зробити пресет більшим. Це і є весь тест на те, чи належить щось сюди.

Що дає кожен пресет і де його підкручують.

ПресетЩо дає йому контрактДе його підкручують
statsаспект числових характеристик із лімітами, регенерацією і модифікаторами, плюс Hook на досягненні порога — 0 HP стає переходом машини, а не if у вашому кодіDeclaration поля; числа лишаються редагованими наживо в панелі
abilitiesаспект набору здібностей, машина фаз застосування, вартість і кулдаунDeclaration здібності
projectilesтип із персистентністю runtime, аспект балістики і Event влучанняDeclaration снаряда — одна заміна атрибута змінює модель польоту
dropsаспект таблиці дропу з вагами і Hook після смертізаписи й ваги таблиці
world objectsмашина станів інтерактивного об'єкта і аспект умови взаємодіїDeclaration пресета або кожен екземпляр на створенні
inventoryвласний тип із ref на предмет каталогу, аспект стека з інкрементом і стеля на власника з оголошеним переповненнямDeclaration типу

У таблиці по одному рядку на preset, бо один рядок — це все, чим вони різняться. Спільне в них нижче, а Inventory — єдиний, у кого є ще й власна сторінка.

Що правдиве для кожного пресета.

ЗавждиЩо це
where it sitsна entity, як аспекти: його дані синхронізуються, як будь-які інші дані, його стани — стани Entity, його RPC — RPC Entity, а його Hooks виконуються в порядку Hooks Entity
tuningце жива конфігурація, а не передеплой, і саме тому панель показує ліміт стати, кулдаун і вагу дропу в одному дереві
declaring oneце акт схеми, а не геймплейний виклик, — і саме тому його відмова іншого роду, ніж відмови, які зустрічає гравець, і обидва види є в Errors нижче
deriving your ownце композиція, а не успадкування класів: Crate — це world-objects плюс stats, і виведена річ усе одно є аспектами на Entity

Помилки

  • Оголосити стату, здібність, снаряд, таблицю дропу чи об'єкт світу — це акт схеми, fn або adm. Гравець чи клієнтський ключ, що це пробує, дістає forbidden, і нічого не оголошується ні повністю, ні наполовину. Це інша відмова, ніж ті, що їх зустрічає гравець усередині виклику, який йому дозволили зробити, — на кулдауні, не може заплатити, немає item:key.bronze, — і кожна з них несе власний код.

Решта відмов пресета — це відмови entity: пресет не вводить понять, тож не вводить і відмов, а їх переказ тут дав би читачеві два місця для перевірки заради однієї відповіді. Дві речі специфічні для самих пресетів:

  • Частково заповнений пресет — це валідаційний збій на деплої. Пресет несе узгоджений набір: половині Declaration відмовляють до відправлення, а не дають їй дивно поводитися в матчі.
  • Пресет не можна позначити властивістю, якій суперечить його власна механіка — аспект, для якого в клієнта немає правил, не можна оголосити передбачуваним, і це теж ловлять на деплої.

Обмеження

Стелі належать entity: аспекти на тип, машини на тип, розмір збереженого екземпляра, частота змін одного екземпляра. Єдина, яку пресет оголошує сам, — це стеля на власника, яку несе власний пресет, з однією з трьох поведінок на межі і без типового значення: refuse · redirect в оголошене відро власника · discard with event. Числа надійдуть із розділом про обмеження платформи.

Шлях користувача

Один снаряд, від натискання гачка до ящика під ногами стрільця. Беруть участь чотири пресети — ability, projectile, stat і drop-table, — і жоден із них не є модулем, який ви монтуєте.

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Rooms

Room — це ігрова сесія; платформі байдуже, що її хостить. Одна абстракція покриває виділений сервер на матч, одну велику спільну карту, розрізану на логічні шари, Room під master-client і міні-гру, що хоститься на бекенді. Нутрощі Room'и наші; ви ведете Room'у ззовні.

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • У вашій грі є сесії — матчі, лобі, підземелля, перегони, — і щось має володіти їхнім життєвим циклом, членством і перепідключеннями.
  • Ви хостите на виділених серверах, на master-client гравця або на самому бекенді, і гравців треба туди маршрутизувати.
  • Одна спільна карта має вести багато логічних сесій — шари, обмежені Visibility.
  • Гравці входять посеред сесії і мають побачити поточну правду — стан Room'и на вході, далі живий трафік.
  • Обірваний зв'язок не повинен коштувати місця — пільгове вікно шаблону (45 с у battle) відновлює те саме членство.
  • Не потрібно, коли фіча — суто запит/відповідь над записами: Data її вже покриває.

Хто що робить

ActorНа цій сторінці
room-ownerреєструє Rooms по всьому процесу; на одному екземплярі Room'и — адмінський інтерфейс на екземпляр: править живу конфігурацію, кікає, замикає, розсилає всім, розпускає
entry-validatorприймає або відхиляє запити на вхід із кодом і причиною
room-visitorпереглядає, входить із даними, перепідключається у пільговому вікні, виходить
spectatorвходить, ні на що не претендуючи; отримує розсилки і живий трафік
match-organizerрезервує місця, які зараховуються до місткості; бронь спливає за терміном шаблону (90 с у battle)

Одним поглядом

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Хоч би де його було написано, відправлений шаблон версіонується і правиться в панелі, тож live-ops переналаштовує тип Room'и без передеплою рушія. Хостинг Rooms, побудованих із нього, — та сама поверхня, до якої дістається інша роль, і її цілком тримають і виділений сервер Unreal, і master-client: зареєструвати, хостити кілька на процес, правити живу конфігурацію, кікати, публікувати, розпускати.

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Клієнт:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

Модель

Room — це Group із правилами, і вона не Entity. Членство походить із groups; її системний стан — платформного рівня, а стан гри живе в entities, обмежених нею. І всередині Room'и не виконується жоден споживацький код — у жодному з режимів авторитету.

Що оголошує тип Room'и.

ОголошуєЩо це
capacityу місцях, і місце — це одиниця місткості, відокремлена від членства: його можна забронювати до входу і втримати попри бездіяльність. Бронь обмежена в часі, з оголошеним дедлайном, після якого місце звільняється без входу
visibilityперелічувана · за назвою чи кодом · прихована
creation modeодин із трьох, і режим on first join зобов'язаний оголосити ініціалізаційний Hook
two independent timeoutsтаймаут бездіяльності — скільки учасник має право мовчати; і TTL порожньої Room'и — скільки живе Room, у якій нікого немає. Два різні питання, тож два оголошення
rejoin windowу ньому повернення відновлює те саме членство і те саме місце, а не робить нового учасника
authority modeour simulation або external authority, і типового значення немає
trust in a reported outcomeдля зовнішнього авторитету: прийняти його · перевірити його Hook'ом · не приймати його. Знову ж таки без типового значення
behaviour when the host dropsперечекати пільгове вікно · закрити Room'у · допустити заміну
map instance and world strataза бажанням, який екземпляр вона займає і які верстви всередині нього
room-scoped entitiesякі з entities студії мають область Room'и — виражено предикатом, а не новим механізмом

Дві машини.

ЧогоСтани
Room'иcreated → open → closed → torn down, де torn down термінальний, а closed означає «немає нових входів», а не «зникла»
членстваactive ⇄ inactive → departed, і departed термінальний для цього членства

Що правдиве для кожної Room'и.

ЗавждиЩо це
losing a connection and leavingрізні події, і наслідок вікна спостережуваний: «повернувся» і «вікно спливло» відрізняються, тож клієнта ніколи не лишають гадати, що саме сталося
no replayперепідключення продовжує з сесійного стану; модуль не обіцяє події проміжку
a spectatorне вироджений учасник: присутній, не займає місця і не входить у список, до якого звертаються як до «гравців», — інакше кожна операція над списком несла б умову
no in-room rolesвласник Room'и — це Actor, що тримає право (Access), а не звання в списку учасників
presenceмає історію, список — ні: хто увійшов, відпав, повернувся і пішов, зберігається; зміни списку не є другою історією
the interfaceналежить конкретній Room'і: ви звертаєтеся до цієї Room'и, а не лише до її типу
three axes, not twoAPI, що охоплює всі Rooms (переглядати, реєструвати, перелічувати); API на одну Room'у, яке викликає будь-який учасник (увійти, вийти); і адмінський інтерфейс на екземпляр — кікнути, замкнути, поправити конфігурацію, закрити, розпустити цю Room'у — відкритий тому, хто тримає адмінську чи хостову роль для того одного екземпляра, а не членству
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

Два режими авторитету.

РежимХто веде Tick
our simulationнаша реалізація Room'и та її модулі
external authorityпроцес, що виконує наш SDK, у Room'і під авторитетною роллю: ігровий сервер студії або клієнт гравця як master-client

Що вирішує режим, а що ні.

Що це
the lineпроводиться за роллю, а не за тим, чий це процес. Виділений сервер — той самий клієнт без рендерингу; від машини гравця його відділяє довіра, а не будова, — і саме тому peer-to-peer не потребує третього режиму, будучи Room'ою в режимі зовнішнього авторитету, чий авторитет — клієнтський хост
what is identicalправила входу, присутність, перепідключення і кожне Declaration — у всіх трьох. Різниться лише те, який процес тримає авторитет і скільки його цьому процесові дано
the room does not moveвсередині Room'и не виконується жоден споживацький код у жодному з режимів, і сесійний та персистентний стан лишаються в нас в обох. Master-client — це учасник, що тримає авторитетну роль: Tick обчислюється там, Room там не живе
trust in the outcomeокреме оголошення на типі Room'и — прийняти його · перевірити його Hook'ом · не приймати його, і без типового значення — а не властивість режиму

Хостинг Room'и.

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
ЗавждиЩо це
the handleтой самий об'єкт, яким користується клієнтська вкладка: немає ні хостового бутстрапу, ні серверного хендла. Він відповідає на ці виклики, бо роль Actor'а включає їх у рантаймі — виділений сервер або master-client, що працює під хостовим ключем (Access)
registrationбере ключ ідемпотентності, бо інакше таймаут на ній невідновний: повторіть виклик із тим самим ключем — і отримаєте ту саму Room'у, а не другу, чиєї адреси ніхто не знає

Вхід, присутність і перепідключення.

Що це
entryце валідований запит: той, хто входить, подає дані на вході, а вхідний Hook приймає або відхиляє з кодом і причиною. Саме з цих даних учасник себе ініціалізує — у battle спорядження, внесене на вході, — це те, з чим спавниться Tank учасника
seats and reservationsMatchmaking забирає слот на термін броні шаблону — 90 секунд у battle, — а далі клієнт входить напряму. Броні зараховуються до місткості, і сплив звільняє місце з Event, а не мовчки
a drop is not a leaveвідпалий учасник тримає своє місце на час пільгового вікна (45 с у battle) і перепідключається в те саме членство; сплив робить це виходом, і Event несе, чим із двох воно було. Тому, хто повернувся на секунду пізніше, кажуть, що Room жива, а членство ні, — і це навмисно інша відповідь, ніж «такої Room'и немає»
late join is state, not a journalтой, хто входить пізно, отримує поточний стан Room'и, а далі живий трафік. Events, надіслані за його відсутності, не програються повторно, як і ті, що їх пропустив учасник, який повертається: усе, що має пережити проміжок, — це стан. Міна, яку заклав гравець, — це Entity в області Room'и, а не повідомлення MinePlaced, яке хтось мусить упіймати

Помилки

  • Room, якої не існує або яку ховає предикат, і розпущена Room відповідають не знайдено — відмова ніколи не розкриває Room'у, якої вам не можна бачити.
  • Вичерпана місткість — це конфлікт, і заброньовані місця вважаються зайнятими; варто повторити, щойно місце звільниться. Закрито для входів — так само конфлікт, варто повторити, якщо відкриється.
  • Вхід, відхилений правилом, — це конфлікт; відхилений Hook'ом несе власний код і причину Hook'а, тож «правило гри сказало ні» ніколи не надходить у вигляді збою транспорту.
  • Вікно повернення спливло — це конфлікт: входьте як новий учасник, із новим місцем.
  • Прострочена бронь — це конфлікт: беріть нову.
  • Екземпляр карти, недоступний або неіснуючий, — це валідаційна відмова.
  • Переміщення, яке цільова Room відхилила, — це конфлікт, і що робити, залежить від його причини.
  • Створення понад ліміт Rooms відповідає як рейт-ліміт або як конфлікт, залежно від того, який це був ліміт.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Місткість Room'и — вхід відхиляється як конфлікт, заброньовані місця вважаються зайнятими.
  • Rooms на проєкт — створення відхиляється як конфлікт.
  • Rooms на Actor'а — створення відхиляється, і вже створені Rooms ніколи не розпускають, щоб звільнити місце.
  • Частота створення Rooms — рейт-ліміт із терміном.
  • Таймаут бездіяльності учасника — примусовий вихід з Event і оголошеною причиною.
  • TTL порожньої Room'и — розпуск з Event; вимикається на типі персистентної зони.
  • Термін броні місця — звільнення з Event.
  • Rooms на одному екземплярі карти — створення на зайнятому екземплярі відхиляється, якщо тип не оголосив спільне зайняття.
  • Розмір payload'а Event'а Room'и — публікація відхиляється до відправлення, ніколи не обрізається.

Шлях користувача

Один матч на виділеному сервері, від входу гравця до HUD, що показує, хто приєднався.

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

Хто що бачить і яка машина це веде

Два питання, що звучать як одне. Хто що бачить — про клієнта: який зріз стану Room'и доїжджає до якого гравця. Яка машина це веде — про хост: який процес володіє Entity і який володітиме наступним. Слово, що їх змішує, — replication: в ігровому рушії воно зазвичай називає перше питання, а тут — друге.

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
Ви маєте на увазіЧитайте
який клієнт отримує який стан і скільки йогоVisibility, разом із Data та Prediction
яка машина володіє Entity і що стається, коли вона вмираєWhat Survives Losing a Host

Вони оголошуються у двох різних місцях

Ні те, ні інше не налаштовується в рантаймі, і спільної Declaration у них немає.

Оголошується наЩо називає
хто що бачитьаспекті — Data, Visibilityпредикат видимості, стелю об'єктів і її порядок, які сусідні області видно, і режим доставки
яка машина це ведетипі Room'и — Roomsрежим авторитету, наскільки зовнішньому авторитету вірять щодо наслідку, і поведінку при падінні хоста

Різняться вони й тим, що стається, якщо не сказати нічого. Аспект без власного правила видимості доставляється у спільному пакеті — це типова поведінка, і для маленької Room'и вона правильна. Тип Room'и, що не назвав режим авторитету, відхиляється: типового значення немає, бо обрати між нашою симуляцією та зовнішньою за вас ніхто не може.

The live game

Visibility

На 40 гравцях знімок усієї Room'и годиться. На 200 — уже ні. Зона видимості вирішує, хто що отримує, оголошеним предикатом, а не перемикачем, який ви клацаєте на кожному об'єкті. Розсилка і пакети на кожного Actor'а — це два оголошені режими доставки однієї моделі, тож перехід між ними — це налаштування, а не переписування. Це оптимізація каналу, а не дозвіл — про це див. Access.

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

Одну оголошену модель — предикат, шари, рівні деталізації — читають двома способами. Перехід між ними — налаштування, а не переписування, бо обидва є прочитаннями того самого Declaration.

РозсилкаПакети на кожного Actor'а
Надсилаєусю Room'у, усімкожному гравцеві лише той зріз, який обирають його правила
Пасуємаленькій Room'і; це типове значеннянатовпу, де розмір пакета має лишатися передбачуваним
Читає Declarationодин раз, на Room'уна кожного Actor'а

Те, що не має протекти, відсутнє в пакеті, а не приховане на клієнті — його ніколи не надсилали, і саме це робить його властивістю безпеки, а не смуги пропускання.

Коли застосовувати

  • Ваші Rooms переростають розсилку на всю Room'у — 200 гравцям потрібні потоки околиці на кожного клієнта, а не кожна Delta.
  • Стан не має протікати: туман війни й поля лише для власника ніколи не мають надсилатися, а не ховатися на клієнті.
  • Кілька сесій ділять одну карту і не мають бачити одна одну — шар є ще одним предикатом.
  • Розмір пакета має бути передбачуваним у натовпі — обмежте кількість об'єктів і оголосіть порядок, щоб «найближчі N» були обіцянкою, а не випадковістю щільності.
  • Гравець на межі має бачити за неї — оголосіть, які сусідні області видимі, бо типово це лише його власна, а межа інакше читається як стіна порожнечі.
  • Не потрібно, коли Room маленька: режим доставки спільним пакетом її вже покриває.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує предикат видимості, стелю об'єктів і її порядок, які сусідні області видимі, і режим доставки
anyпідписується і отримує те, що впускає зона; може знизити стелю об'єктів для себе в оголошених межах

Одним поглядом

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

Модель

Що оголошує правило видимості.

ОголошуєЩо це
predicateсаме правило, тією самою мовою предикатів, що й предикати доступу та охоронці переходів. Радіус, екземпляр карти, команда і володіння — це конкретні випадки предиката, а не окремі механізми: у контракті рівно одна така мова
object cap і його порядокправило може обмежити кількість об'єктів, і тоді порядок вибору оголошують, а не виводять: «найближчі N» — це предикат плюс упорядкування за відстанню плюс стеля. Самим предикатом цього не виразити, бо предикат відповідає на «чи підходить цей рядок», а не на «який із підхожих ближче»
neighbouring areasчи видимі об'єкти із сусідньої Room'и або екземпляра карти і які саме. Ніколи не є неявним: без оголошення областю є та, в якій перебуває отримувач
delivery modeshared packet — те саме всім, дешево по CPU; або per-actor packet — кожному своє за його зоною, дорого по CPU і необхідно на великих населеннях

Що правдиве для кожної зони.

ЗавждиЩо це
visibilityне є дозволом: те, що зона ховає, може бути доступним за дозволом, і навпаки. Перше — оптимізація каналу, друге — безпека, а їх змішування означає, що налаштування туману війни мовчки розширює дозволи або що ACL починають застосовувати заради економії трафіку і права починають залежати від відстані
the recipientможе знизити стелю: у межах оголошеного максимуму і ніколи нижче за оголошений мінімум, бо предикат однаковий для всіх, чий контекст збігся, а розмір пакета — проблема отримувача
truncationспостережуване: отримувач дізнається, що пакет обрізали і за яким порядком. Мовчазне обрізання заборонене — його не відрізнити від того, що об'єктів більше немає
degradationоголошена: коли бюджет на складання пакетів на кожного Actor'а вичерпується, платформа відкочується до спільного пакета як оголошено, а не починає губити отримувачів навмання: гірше, але відомим способом, замість витоку, що не відрізняється від бага гри
packet shapeслабка обіцянка: розмір і склад пакета не повинні б дозволяти вивести існування прихованих об'єктів, і це навмисно слабше за «повинні» — повністю сховати метадані потоку на реальних обсягах недосяжно. Там, де витік існування важить, беріть дозволи, а не зону

Помилки

  • Неоголошена ціль підписки — валідаційна відмова.
  • Немає дозволу підписатися — відповідь forbidden або not found залежно від того, чи є існування цілі таємницею: сама відмова не має видавати того, чому вона відмовляє.
  • Позиція відновлення, яка не розбирається, — це поганий запит, а не мовчазний перезапуск із цього моменту.
  • Підписка, яку закрила платформа, і вичерпана кількість підписок — обидві є конфліктами.
  • Розширення огляду — це fn. Видача розширеного огляду й задання ярусів деталізації сесії гравця відповідають forbidden, і її огляд не змінюється: клієнт-спостерігач не може розширити власну видачу.
  • Екземпляр, який огляд викликача виключає, відповідає not found — тим самим, що й неіснуючий: forbidden підтвердив би, що за стіною щось стоїть.
  • Читання вартості пакета на Actor — це fn adm: хмарна функція або панель, але ніколи клієнт, що питає, скільки коштує за ним спостерігати.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Вартість пакета на кожного Actor'а — на вичерпанні оголошена деградація до спільного пакета з повідомленням, ніколи довільна втрата отримувачів.
  • Об'єкти на правило — обмежені з оголошеним порядком і спостережуваним прапорцем обрізання.
  • Підписки на Actor'а — нову відхиляють, наявні тривають.
  • Розмір Delta — Delta розділяється, а не обрізається, і розділення спостережуване.
  • Частота надсилання — верхня межа, а не гарантія.

Шлях користувача

Правило радіуса перетворює Room'у на 200 гравців у потоки околиці для кожного клієнта.

«Хто це бачить?» і «що вони бачать?» — обидва можна запитувати, бо дорогою є та сесія налагодження, у якій ви на них не відповісте. Вартість пакета на кожного Actor'а — це читання першого класу, і в коді, і в панелі.

The live game

Що переживає втрату хоста

Хост помирає посеред матчу. Матч — ні. Ця сторінка про друге значення слова «реплікація» — яка машина володіє Entity і яка володітиме нею наступною. Перше значення — який клієнт отримує який стан — це Visibility разом із Data і Prediction. Хто що бачить і яка машина це веде — це там, де ці два значення розрізняють.

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

Стан Room'и не копіюється між хостами

В Entity рівно один власник за раз, і жодна друга машина не тримає живої копії, готової перехопити.

Дві копії, що приймають той самий постріл, мусили б домовитися про порядок, у якому ці два постріли приземлилися. Домовлятися про порядок тридцять разів на секунду між машинами — це консенсус, а консенсус ставить затримку рівно туди, де гра її не терпить. У єдиного власника такої проблеми немає, і кожен механізм нижче існує, щоб єдиний власник виживав, а не щоб його обійти.

Що справді реплікується — це присутність: який Actor на якому вузлі. Це маленький факт, який змінюється повільно, тож маршрутизація може знати його всюди, не платячи за домовленість про щось рухоме.

Оголошений стан зберігається поза хостом

Оголошений стан не є приватним для процесу, який його тримає. Він знімається з оголошеним інтервалом, тож заміна може продовжити з останнього знімка, коли попередній хост перестає відповідати, а гравець заходить назад через звичайне пільгове вікно rooms.

Звідси випливають три речі, і це чесна форма цього:

  • У заміни стан цілий, але станом на знімок. Повний, а не поточний. Заміна хоста коштує тієї гри, що була між останнім знімком і втратою, а інтервал і є тим, що фіксує цей найгірший випадок.
  • Неперервність Tick'а не переноситься через зміну авторитету. Переміщення, яке виконує сама платформа, зберігає стан Tick'а учасника; заміна авторитету цього не обіцяє. Rooms — це там, де оголошують обидва разом із тим, що стається, коли пільгове вікно минає.
  • Усе, що ви тримали лише в акторах рушія, іде разом із процесом. Воно ніколи не було оголошене, тож поза тим хостом його ніхто ніколи й не мав.

Деплой — це той самий шлях, тільки без утрат

Розвантажити хост — перестати розміщувати там нові Rooms, дати сесіям у польоті завершитися чи передатися, а далі відпустити його — це шлях заміни хоста, пройдений навмисно і з попередженням. Саме тому деплой без убивання живих сесій — не другий механізм, який треба будувати і якому треба вірити: це той самий, запущений свідомо, а не крахом.

Хост Room'и дізнається про це так само, як дізнається будь-що: платформа заздалегідь попереджає, що Room'у буде закрито або передано з причини на її боці.

Що стається, коли вікно минає, оголошено, і типового значення немає. Тип Room'и, чий авторитет живе поза платформою, називає один із трьох результатів його втрати — перечекати оголошене вікно, закрити Room'у або допустити авторитет-заміну. Промовчати — не той варіант, який пропонує Declaration, бо альтернатива — це саме той збій, задля запобігання якому воно й існує: Room із мертвим авторитетом, яка все ще приймає входи і тримає місця, показуючи кожному учасникові живу сесію, в якій нічого не відбувається.

Яка саме машина — не частина вашої поверхні

Ви ніколи не називаєте вузол. Той, хто створює Room'у, не обирає, де вона виконуватиметься, і жодна операція не бере хост аргументом — розміщення за платформою і лишається за платформою, щоб вона могла перенести Room'у без того, щоб ваш код був написаний під те, де вона була раніше.

Якщо ви хостите Rooms самі — виділений сервер чи master-client — усе те саме плюс одне: вам кажуть згортатися, і завершити чи передати свої сесії всередині пільгового вікна — ваша справа. Rooms — це там, де хост реєструється для цього зв'язування, а Авторитетність — це про те, чому хост тримає лише ті права, які йому дали.

The live game

Matchmaking

Завести гравця в потрібну Room'у. Тікети описують гравця і фільтрують інших. Матчмейкер визначає розміщення, резервує місце, а далі ігровий трафік іде прямо в Room'у.

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

Матчмейкер стоїть на шляху один раз, щоб вирішити, де ваше місце. На шляху матчу його немає: його наслідок — це розміщення і обмежена в часі бронь місця, а від входу і далі ігровий трафік іде просто в room. Тож завантажена черга ніколи не стає завантаженою грою.

Коли застосовувати

  • Вам потрібно маршрутизувати гравців у rooms за оголошеними критеріями — режим, регіон, ранг, — а не самописним списком лобі.
  • Критерії матчу мають походити з даних платформи, а не з заяви клієнта: штампуйте ранг у Hook'у до постановки в чергу.
  • Черги мають розширюватися з часом на сервері, поки клієнт тримає один тікет і нічого не опитує.
  • Паті мають потрапити в один матч разом — Group входить цілком або ніяк.
  • Ви запускаєте зовнішній матчмейкер, і вам треба лише перетворити його рішення на розміщення + бронь місця.
  • Не потрібно, коли гравці обирають сесію самі: перегляд rooms і Join це вже покривають.

Хто що робить

ActorНа цій сторінці
playerстворює і скасовує власний тікет і входить у складі паті
match-organizerоголошує черги матчмейкера і послаблення; читає результати розміщення
backend-serviceштампує довірені критерії до постановки в чергу; виконує рішення зовнішнього матчмейкера

Одним поглядом

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

Критерії, які не можна довіряти клієнтові, штампують у Hook'у до постановки в чергу:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

Читання — це читання списку власників із Leaderboards — те саме, яким користується когорта друзів, — і кожен рядок, який воно повертає, несе ранг того власника в повній таблиці. Hook може спитати чужий рядок, бо предикат таблиці дозволяє це хмарній функції; клієнтська сесія гравця, поставивши те саме питання, дістає лише свій.

Модель

Що несе тікет, і ці дві частини одна до одної не зводяться.

ЧастинаЩо цеХто в це вірить
self-descriptionоголошені властивості учасника — рейтинг, режим, мова, обрана картаніхто без перевірки: це заява викликача
requirementпредикат, який решта має задовольнитиплатформа, бо саме вона його застосовує

Учасник тікета — це Actor або Group: Group входить цілком, і саме це є паті. Її тікет неподільний: Group входить у список цілком або ніяк, бо розділити Group було б іншою обіцянкою, а такої немає.

Що оголошує тип черги.

ОголошуєЩо це
propertiesза іменем і типом. Властивість, не оголошену тут, у тікеті відхиляють як валідаційний збій, а не ігнорують
roster sizeмінімум, максимум і крок сумісності — кратність, за якої список прийнятний, щоб команди «по п'ять» означали п'ять, а не будь-яке число від двох до десяти
requirement ladderупорядкований набір предикатів із вікнами: кожен щабель — це ширша вимога і час, після якого матчмейкінг іде далі. Послаблення — це Declaration, ніколи не довільна логіка в обробнику
the predicate languageта сама, якою користується все інше, і її словник включає власні властивості тікета — «рейтинг у межах ±100 від мого» можна виразити. Без цього двостороння модель не працює взагалі, бо відносні умови — уся її суть
mutualityчи прийнятний список, у якому A приймає B, а B не приймає A. Типового значення немає
ticket lifetimeпісля якого тікет переходить у expired з Event'ом
outcomeRoomPlacement — посилання на Room'у плюс броні в ній, для одночасної гри; або RosterSet — сам лише список, без Room'и і без броней, для асинхронної, де суперник офлайн

Стани тікета. created → queued → matched · cancelled · expired, останні три термінальні.

ЗавждиЩо це
one live ticket per participant per queueдругий — це конфлікт, а не друга заявка: читайте наявний
the reason for a pairingспостережувана: вона доходить до Event'а матчмейкінгу і до історії. Для алгоритмів, що постачаються, це щабель драбини; у реалізації-перевизначенні щаблів може не бути, і тоді причина — це непрозоре значення, яке вона оголошує, — але причина є завжди
expiryце наслідок, а не помилка: «список не зібрався за оголошений час» — це нормальне завершення, доставлене наслідком тікета
the outcomeнадходить підпискою, а не опитуванням. Матчмейкінг займає секунди й десятки секунд, тож опитування перетворило б чекання на навантаження, що росте з довжиною черги, — клієнт тримає один тікет і більше не питає
losing the connection cancels the ticketоголошено, а не виведено: тікет — це заявка грати зараз, а зведення відсутнього гравця робить список гіршим для всіх інших
matchedатомарний: для RoomPlacement, або список зведено і кожен учасник тримає бронь, або тікети лишаються в черзі. Для RosterSet атомарним результатом є сам лише список

Помилки

  • Другий тікет у тій самій черзі — це конфлікт; не повторюйте його, прочитайте наявний.
  • Неоголошена властивість або вимога, що її називає, — це валідаційний збій, а не мовчазне ігнорування, яке спливло б пізніше як «суперників не знайдено».
  • Черга призупинена відповідає unavailable, а не forbidden: права викликача цілі, а ситуація тимчасова, тож повторювати з відступом правильно.
  • Room'у для результату не вдається створити — так само unavailable, з відступом.
  • Бронь не вдалася — це конфлікт, який варто повторити: тікет лишається в черзі.
  • Тікет, якого не знайдено або який чужий, і Actor, якого предикат не впускає в чергу, обидва відповідають not found, тож відмова не розкриває ані тікета, ані черги.
  • «Список не зібрався» ніколи не є помилкою — див. про сплив вище.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Тікети в черзі — створення відхиляють як конфлікт, і наявні тікети не витісняють, щоб звільнити місце.
  • Час життя тікета — перехід у expired з Event'ом.
  • Щаблі драбини — Declaration із завеликою кількістю відхиляють під час оголошення.
  • Розмір Group у тікеті — тікет відхиляють як валідаційний збій.
  • Оголошені властивості на тип черги — відхиляють під час оголошення.
  • Частота створення тікетів — відмова рейт-ліміту зі строком.
  • Зберігання історії матчмейкінгу — після періоду запис недоступний для читання за оголошений період.

Шлях користувача

Від входу до стояння в Room'і матчу, з рангом, проштампованим на сервері. Подорож починається з Auth, бо в тікета є власник: без сесії немає кого ставити в чергу.

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Map

Статичний світ: межі, рельєф, перешкоди і «куди можна ставити речі?». Фізична модель навмисно значно простіша за візуальну: примітиви з footprint і висотою, шари з правилами і один запит допустимої позиції, що його перевикористовує кожен інший модуль.

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Вам потрібен статичний світ — межі, рельєф, перешкоди, — який сервер може запитувати, а не лише рендерити.
  • Спавни, дроп і декорації мають приземлятися в законних місцях: один запит RandomPosition за правилами, без обхідних шляхів.
  • Арени мають перегенеровуватися на кожен матч — оголошений Seed відтворює ту саму карту в баг-репорті.
  • Ящики й стіни ламаються і повертаються — руйновні об'єкти з HP і таймерами респавну.
  • Ботам і projectiles потрібні відповіді про промінь і лінію зору відносно набору перешкод.
  • Не потрібно, коли світ суто візуальний і жоден серверний код не питає, куди можна ставити речі.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує карти, примітиви перешкод, руйновні об'єкти, шари та їхні правила
room-ownerприв'язує карту до Room'и; просить позиції спавну; пускає промені
operatorставить чи прибирає перешкоди й шари з панелі

Одним поглядом

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter і Destructible — це генератори розміщення, а не кидки в рантаймі. Генератор розв'язується, коли публікують версію карти: сорок каменів стають сорока оголошеними примітивами, і опублікована версія несе примітиви, а не правило. Тому той самий Seed дає ті самі сорок каменів у матчі, у повторі й у баг-репорті, — а ліміти геометрії перевіряють один раз, на цьому розв'язаному наборі, до того, як версія дійде до Environment.

Запит, який ставлять усі інші:

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

Модель

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

Два шари, які оголошують різні люди.

ШарЩо він тримає і хто його оголошує
staticрельєф із висотою, примітиви перешкод, межі й місця — авторський контент
dynamicперешкоди, принесені entities в рантаймі: двері, руйновні об'єкти, платформи. Отже, руйновний об'єкт — це entity з життєвим циклом, станами і власником, і картою він стає лише в тій частині, де приносить перешкоду: статичний камінь оголошують у карті, а двері — це Entity, що її приносить. Власного життєвого циклу тут у них немає: він належить Entity

Що оголошує карта.

ОголошуєЩо це
key і versionкарта — це авторський контент: оголошена в коді, адресована за key і версіонована, і версія — частина того, на що посилається Room. Змінювати геометрію випущеної версії заборонено; правка — це нова версія
terrainполе висот — регулярна сітка з оголошеним кроком, і крок є оголошеною межею точності, тож запит висоти відповідає за ним, а не точно. Рельєфу може не бути: арена в порожнечі законна
obstaclesзакритий набір примітивів — коробка, сфера, капсула, опукла оболонка з оголошеним лімітом вершин. Довільний трикутний меш не приймають, і це умова того, що серверна перевірка взагалі можлива
passability kind на перешкодунепрохідна · прохідна для оголошеного класу · закриває лише лінію зору. Один примітив служить і стіною, і кущем, і різницю оголошують, а не моделюють двічі
boundsоб'єм, поза яким позиція недопустима
world strataоголошені просторові верстви всередині карти — земля, підземелля, повітря. Це декларації геометрії й адресації
locationsіменовані місця чи області — точка спавну, зона захоплення, коридор. Місце відповідає на де, ніколи на що стається: ігрової логіки воно не несе
placement generatorза бажанням, правило, що виробляє примітиви, — кількість, мінімальна відстань, область, зерно. Воно розв'язується під час публікації версії, детерміновано за зерном, і далі карта тримає примітиви, а не правило

Страта світу і екземпляр карти ніколи не є синонімами.

Що це
world stratumоголошення всередині карти — земля, підземелля, повітря
map instanceнезалежна копія в рантаймі опублікованої карти. Екземпляри ділять незмінну опубліковану геометрію і мають незалежні динамічні перешкоди й незалежні списки entities. Room займає екземпляр і може обрати верстви всередині нього

Що правдиве для кожного запиту.

ЗавждиЩо це
one geometric canonуся геометрія в оголошеному координатному каноні платформи, а точність кожного геометричного поля оголошують на полі
the world modelце спрощення: серверна геометрія не є артовою моделлю і не зобов'язана нею бути
an answer names its instance and its momentна запит відповідають зі статичного шару карти плюс динамічних перешкод того екземпляра, про який спитали, і відповідь оголошує мить, для якої вона істинна: динамічні перешкоди змінюються, тож відповідь — це знімок
the valuesmanaged, а не seeded: геометрія не є щоденним підкручуванням дизайнера — правку з адмінської консолі відхиляють, а не тихо тримають
movement and contactрозв'язуються не тут: він відповідає, чим є простір; чи допустима позиція і яка відповідь — це справа collision, а застосувати це — справа locomotion

Помилки

  • Карта, версія чи екземпляр, яких не знайдено, відповідають not found, і відкликана версія так само — повторювати марно.
  • Опублікувати змінену геометрію під наявною версією — це конфлікт: робіть нову версію.
  • Збої публікації виявляються на оголошенні, на деплої, ніколи в рантаймі: карта понад ліміт примітивів, опукла оболонка понад свій ліміт вершин і довільний меш як перешкода — усе це валідаційні відмови до постачання.
  • Запит висоти поза межами не є помилкою — це оголошена відповідь «поза межами», і вона відрізняється від «всередині перешкоди», бо в одному випадку клієнт розвертається, а в іншому обходить.
  • Перевищена частота запитів відповідає в категорії рейт-ліміту зі строком.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Примітиви перешкод на карту, вершини опуклої оболонки, роздільність поля висот, розмір меж, місця на карту — кожне з них відхиляють на публікації, а не під час запиту: карта, яка постачається, — це карта, яка вже вміщується.
  • Екземпляри карти на карту — створити ще один відхиляють як конфлікт; наявні екземпляри ніколи не відпускають, щоб звільнити місце.
  • Скільки версій тримають — найстарішу застарілу версію відкликають, а версію під живою Room'ою — ніколи.
  • Частота запитів до простору — рейт-ліміт зі строком.

Шлях користувача

Запланований авіадроп просить у карти законне місце, а гравець під'їжджає його забрати. Таблиця дропу — це пресет entity: Declaration на Entity, а не модуль, який ви монтуєте.

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Collision

Прив'яжіть трансформ до карти перешкод; оголосіть, що робить контакт. Collision виконується всередині симуляції платформи. Ви оголошуєте тіла, шари й відповіді і підписуєтеся на контакти.

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

Коли застосовувати

  • Рухомі entities мають розв'язувати контакти на сервері — ковзання, зупинка, відскок — без написаної руками процедури відхилення.
  • Геймплей реагує на дотик: підбиранки збираються на перетині, тригерні об'єми запускають машину станів entity.
  • Locomotion і projectiles потребують розв'язання заметанням відносно набору перешкод map.
  • Попереднім переглядам розміщення чи прицілюванню потрібні «а чи влізе воно сюди?» і запити перетину з об'ємом.
  • Не потрібно, коли фізично ніщо не зустрічається: геймплей запит/відповідь над записами — це звичайна Data.

Хто що робить

ActorНа цій сторінці
room-ownerоголошує тіла, шари й відповіді; запитує перетини й контакти

До яких Room'ів це стосується. Модуль виконується там, де симуляцію крокує платформа, — у Room'ах, оголошених із Host = "Backend". Якщо симуляцією володіє ваш власний game server (PlayServ як метасервер), рух, колізії та передбачення лишаються на боці рушія, а ця сторінка описує розміщену на платформі альтернативу, а не вимогу.

Одним поглядом

Форма, шар і те, що робить контакт, — усе сидить на самому тілі; ніщо не оголошує пар шарів здалеку:

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

Контакт — це Event, і модулі на нього підписуються. Hook'а на контакт немає: на момент, коли він існує, крок його вже розв'язав, тож відхиляти нема чого. Там, де студії потрібні інші правила, вона перевизначає перевірки допустимості й шляху як реалізацію — див. Extensibility, — а відповідь лишається оголошеною.

Модель

Що оголошує тіло.

ОголошуєЩо це
shapeпримітив із закритого набору — сфера, капсула, коробка — з оголошеними розмірами. Довільного меша не пропонують: те саме обмеження і та сама причина, що й у серверної моделі світу в map
where it livesна аспекті, разом із трансформом: це і є одиниця політики, і тіло ділить із трансформом одну долю
how its path is checkedstepwise — перевіряють кінцеву позицію кроку, швидко, і швидке тіло проходить крізь тонку перешкоду; або swept — перевіряють відрізок між позиціями, дорожче, і тунелювання виключене всередині кроку. Оголошується, ніколи не обирається реалізацією за швидкістю: чи може снаряд пролетіти крізь стіну — це властивість гри, а не оптимізація
areas it participates inусередині яких об'ємів його рахують
its relation to the art modelжодного не вимагають: тіло — це спрощення, і розходження з артовою моделлю допустиме в оголошених межах

Відповідь оголошують на пару — рід прохідності перешкоди × тип тіла — і вона походить із закритого набору:

ВідповідьЩо вона означає
stopрух припиняється на останній допустимій позиції
slideрух триває вздовж перешкоди тією складовою, яка допустима
bounceнапрямок відбивають, а швидкість множать на оголошений коефіцієнт
dampрух триває зі швидкістю, помноженою на оголошену частку
passперешкода не впливає на рух, але контакт усе одно спостережуваний
cease to existEntity закінчується — снаряд об стіну

Коефіцієнти — це оголошені значення, а не обчислені з мас і матеріалів: у цьому контракті немає ні тих, ні інших.

Що правдиве для кожної перевірки.

ЗавждиЩо це
every pairмає відповідь: відсутня пара — це дефект Declaration, який відхиляють на деплої, а не зустрічають у бою
the response tableдоступна для читання клієнтом: та сама таблиця, за якою рахує авторитет, тож клієнт і сервер, яким дали одне Declaration, відповідають на один контакт однаково
reproducible within one authority, not across platformsтой самий ввід у тому самому порядку дає той самий результат усередині одного процесу і однієї збірки. Побітово однакових результатів на різних платформах і збірках не обіцяють, а мережева модель, побудована на припущенні, що колізії скрізь рахуються однаково, побудована на піску
simultaneityоголошена: коли два рухомі тіла зіштовхуються всередині одного кроку, порядок розв'язання оголошений і детермінований. Порядок обходу сховища, порядок приходу вводу і випадковість не можуть бути його підставою
one contact, one factконтакт двох тіл спостережуваний обома сторонами як один факт з одним ідентифікатором, а не як дві незалежні події
extension points sit on the step, not on a contactдо кроку трансформ можна змінити, після нього є спостереження. Контакт уже стався, тож відхиляти нема чого; інші правила — це оголошене перевизначення перевірок допустимості й шляху, і таке перевизначення має бути доступним і клієнтові
the moduleсам нічого не рухає: він відповідає, чи допустима позиція і яка відповідь, а застосувати це — справа locomotion

| a contact is an event | і саме тому модулі підписуються, а не зчіплюються: машина станів пастки прив'язує перехід до входу в тригерний об'єм, drops збирають на перетині, а projectiles розв'язують влучання заметанням цього модуля |

Помилки

  • «Недопустимо» — це відповідь, а не помилка, і вона називає, яка з трьох причин: поза межами, зайнято статичною перешкодою або зайнято тілом іншої Entity. Клієнт реагує на ці три по-різному — розвернутися, обійти або зачекати, — тож звести їх до «ні» коштувало б поведінки.
  • Збої Declaration виявляються на деплої, ніколи на першому контакті: тіло з формою поза закритим набором, тіло на аспекті без трансформа і пара без оголошеної відповіді — усе це відхиляють на деплої. Колізія стається в бою, і збій у рантаймі там спостерігають як стіну, що зникла.
  • Entity або місце не знайдено — відповідь not found, і повторювати марно.
  • Перевищена частота перевірок відповідає в категорії рейт-ліміту, зі строком, до якого повтор марний.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Тіла в Room'і — оголосити ще одне відхиляють як конфлікт; наявні тіла ніколи не прибирають, щоб звільнити місце.
  • Контакти на крок — надлишок ніколи не викидають мовчки: або крок відхиляють, або порядок відсікання оголошений.
  • Розмір тіла відносно кроку сітки карти — відхиляють на деплої, бо тіло, менше за крок поля висот, провалюється крізь рельєф, а це не може бути несподіванкою в рантаймі.
  • Області, всередині яких може бути одне тіло, — надлишок відхиляють на деплої.
  • Частота перевірок на Actor'а — рейт-ліміт зі строком.
  • Тіла у відповіді «хто в цій області» — обрізають за оголошеним порядком, і прапорець обрізання обов'язковий.

Шлях користувача

Тригерний об'єм, машина станів і двері: усю проводку роблять Events контакту. Плита і двері — це об'єкти світу, пресети entity, а не модулі, які ви монтуєте.

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Locomotion

Ви оголошуєте, як річ рухається; інтегратора не пише ніхто. Модель руху перетворює послідовний ввід на авторитетний рух, інтегрований із collision, записаний для prediction і модифікований бафами, дебафами й рельєфом.

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Entities рухаються під вводом гравця — танки, персонажі, транспорт — і рух має бути авторитетним на сервері.
  • Ви радше оголосите ліміти швидкості, прискорення й швидкості повороту, ніж писатимете інтегратор.
  • Геймплей штовхає тіла: відкидання Impulse, Teleport і модифікатори на кшталт багна з тривалістю.
  • Рух має відчуватися миттєвим: та сама оголошена модель крокує на сервері й у петлі prediction.
  • Не потрібно, коли позиції змінюються лише дискретними кроками — синхронізоване поле на entity це вже покриває.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує моделі руху, обмеження і зв'язування
room-ownerзастосовує імпульс, телепорт і модифікатори з хоста
playerподає послідовний ввід; читає стан руху

До яких Room'ів це стосується. Модуль виконується там, де симуляцію крокує платформа, — у Room'ах, оголошених із Host = "Backend". Якщо симуляцією володіє ваш власний game server (PlayServ як метасервер), рух, колізії та передбачення лишаються на боці рушія, а ця сторінка описує розміщену на платформі альтернативу, а не вимогу.

Одним поглядом

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

Ввід клієнта — це послідовний намір. Платформа крокує рух:

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

Серверні дієслова:

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

Модель

Що Entity оголошує, щоб рухатися.

ОголошуєЩо це
movement modelодна з набору, що постачається, — steering, tank, character, vehicle, flying — як кілька реалізацій одного кроку, з оголошеними умовами вибору і типовим значенням. Крок — це чиста функція (pose, input, dt) → pose
parametersоголошені значення, які клієнт може прочитати; без них передбачення систематично розходиться. Вони seed — дизайнер їх підкручує, і деплой не має мовчки втратити правки, — тоді як ліміти, на яких стоїть античіт, можуть бути managed, і тоді правку з адмінської консолі відхиляють
limitsмаксимальні швидкість, прискорення й гальмування, максимальний поворот на ввід, множник заднього ходу і окрема швидкість обертання для частин. Поворот на ввід оголошують окремо від швидкості обертання навмисно: одне обмежує миттєвий стрибок, друге — неперервний темп, і це різні захисти
behaviour on stale inputstop, continue until a declared deadline або continue indefinitely. Типового значення «як було» немає — гравець, у якого відпала мережа, їхав би далі
pose toleranceнаскільки далеко заявлена поза може стояти від серверної, і це може різнитися за станом: стоїть, рухається і щойно після респавну — три різні допуски
step rate and catch-up capяк часто виконується крок і скільки кроків можна взяти за раз, коли сервер відстає
impulse kindsкожен зі своєю величиною і своєю манерою згасання

Що правдиве для кожного кроку.

ЗавждиЩо це
the module ownsпозицію й орієнтацію Entity в часі, і більше нічого. Історію цих позицій тримає entity, а не тут, тож у «де був гравець 300 мс тому» рівно одна відповідь замість двох буферів із різними періодами
authorityсерверний: у режимі our simulation клієнт надсилає намір, ніколи результат
collisionsрозв'язуються не тут: він питає collision, чи допустима позиція і яка відповідь, і власної таблиці відповідей не тримає
inputце намір: «вперед», «праворуч», «поверни башту туди» — приймається як є, бо він нічого не стверджує про світ
a claimed poseце заява: ніколи не факт. Поза оголошеним допуском її затискають до найближчої допустимої пози, і це дає спостережуваний pose_clamped
input sequencingобов'язкова: той самий номер послідовності ніколи не застосовують двічі, а менший відкидають
a limitзатискає, а не відмовляє: «десять метрів уперед за цей Tick» стає тим, що допустимо, а не помилкою. Саме це робить ліміт античітом за побудовою — сервер фізично не може видати незаконної пози, — і саме тому клієнта не заливає відмовами щокадру
an impulse obeys the same constraintsвіддача, штовхання, вибух і відкидання приходять поза вводом, і жоден із них не обходить collision: віддача не заганяє танк у камінь
identical rules, not identical bitsпобітово однакового результату між платформами не обіцяють. Обіцяють ті самі правила і відтворюваність усередині одного авторитету
the stepчистий і керований Tick'ом: той самий код крокує рух на сервері й усередині петлі передбачення клієнта, і саме це робить узгодження точним

Помилки

  • Відсутня модель руху на Entity, частково заповнений пресет і імпульс без оголошеного згасання — усе це валідаційні збої на деплої, а не в рантаймі: імпульс без кінця — це дефект Declaration, тож він ніколи не доходить до гравця.
  • Очікуване покоління не збіглося — це збій передумови, який варто повторити після повторного читання: ввід, надісланий до респавну, не має застосуватися після нього.
  • Перевищена частота вводу відповідає в категорії рейт-ліміту зі строком.
  • Entity некерована — це конфлікт, і повторювати має сенс лише після зміни стану.
  • Три речі не є відмовами в жодному напрямку, і всі три спостережувані. Застарілий ввід відкидають, намір понад ліміт затискають, а позу понад допуск затискають як pose_clamped. Зробити будь-що з цього мовчки означало б лишити клієнта в переконанні, що воно застосувалося, і назавжди розійтися з сервером.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Максимальні швидкість і прискорення — затискаються, ніколи не відхиляються.
  • Максимальний поворот на ввід — затискається.
  • Частота вводу на Actor'а — рейт-ліміт зі строком.
  • Кроки надолуження — понад стелю кроки відкидають з оголошеним наслідком: час симуляції відстає, і це спостережувано, а не надолужується стрибком, який читається як телепортація всіх одночасно.
  • Величина імпульсу — затискається до оголошеного максимуму.
  • Одночасні імпульси на Entity — новий витісняє найстаріший, і витіснення спостережуване; мовчазного необмеженого підсумовування немає.
  • Час життя заявленої пози — старішу за оголошений період не розглядають.

Шлях користувача

Подорож одного відкидання: player їде, attacker в іншому танку стріляє, і імпульс проявляється узгодженою позою на екрані жертви. Здібність і снаряд — це пресети entity: Declarations на entities, а не модулі, які ви монтуєте.

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Prediction & Lag Comp

Гравець натиснув стрибок 50 мс тому. Пакет надійшов лише зараз. Він не впав. Передбачення вперед і компенсація назад над даними, що несуть свій справжній час події: клієнт відчувається миттєвим, сервер лишається правим, а влучання судять у часовій лінії стрільця.

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Ввід має відчуватися миттєвим під затримкою, поки сервер лишається авторитетним, — передбачайте вперед, узгоджуйте у разі розбіжності.
  • Влучання мають бути розсуджені в часовій лінії стрільця: ResolveAt відмотує хітбокси до заявленого Tick'а погляду.
  • Дуги прицілювання й маркери приземлення мають збігатися з результатами — клієнт і сервер прогнозують ту саму Trajectory.
  • Критичні для гри поля ніколи не повинні відкочуватися — оголосіть, що передбачається, а що чекає на сервер.
  • Гумовий ефект треба підкручувати: вікна, допуски й телеметрія хибних передбачень на кожну Entity.
  • Не потрібно, коли затримка не болить: покрокові чи повільні ігри чудово живуть на звичайних Deltas Data.

Хто що робить

ActorНа цій сторінці
schema-authorоголошує передбачувані поля проти лише авторитетних; задає вікно передбачення
room-ownerрозв'язує влучання на історичному стані; відмотує світ
playerпередбачає й узгоджує рух; підписується на виправлення

До яких Room'ів це стосується. Модуль виконується там, де симуляцію крокує платформа, — у Room'ах, оголошених із Host = "Backend". Якщо симуляцією володіє ваш власний game server (PlayServ як метасервер), рух, колізії та передбачення лишаються на боці рушія, а ця сторінка описує розміщену на платформі альтернативу, а не вимогу.

Одним поглядом

Слово покриває три різні речі, змішувати їх не можна, і кожна має власну статтю. У них різні авторитети й різні режими відмови, і одне слово на всі три означає, що підкручування одного мовчки змінює два інші.

МеханізмЩо він робитьВиконується наКоли він хибний
Передбачення власного рухузастосовує оголошену модель до вашого власного вводу, не чекаючи на серверклієнтівиправлення, перегране і згладжене
Показ інших гравцівмалює інші entities між станами, що приходятьклієнтівидимий ривок
Компенсація лагувідмотує цілі до миті, яку бачив стрілецьсерверіхтось помирає несправедливо

Ця сторінка — вузол: спільна модель, спільні Declarations і пресети, що обирають комбінацію за вас. Три статті — це там, де кожен механізм насправді пояснено.

Чотири пресети, і «без передбачення» — один із них.

ПресетПередбачає вашеКомпенсуєЗгладжує інших
shooterтаку вікні приблизно півтори секундитак
arcadeтакнітак
observerнінітак
без передбаченнянініні — стан приїжджає від авторитету з оголошеним вікном інтерполяції

Останній — не заглушка. Покроковим іграм, стратегіям і більшості мобільних тайтлів передбачення не потрібне взагалі, а оголошене «ми не передбачаємо» каже клієнтові показувати стан як є, а не вгадувати.

Нічого з цього не діє під зовнішнім авторитетом. Усі три механізми існують для Rooms, які веде наша симуляція. Коли Tick'ом володіє ігровий сервер студії чи master-client, передбачення — це справа того, хто його веде, — див. Хто веде Tick.

Модулеві не належать ані модель руху, ані таблиця реакцій, ані геометрія, ані власне вікно історії. Вони належать Locomotion, Collision, Map і Entity відповідно. Передбачення застосовує їх раніше або читає їх назад; воно ніколи не оголошує другої копії.

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

Розв'язання з компенсацією лагу відповідає на «де були всі, коли цей постріл зробили»:

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Прогноз траєкторії, спільний для сервера і клієнта (дуги прицілювання, маркери приземлення). Прогноз — це операція цього модуля, екстрапольована відносно набору перешкод map, тож обидві сторони малюють ту саму дугу з тих самих вхідних даних:

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

Модель

Передбачуваність оголошують на аспекті — а аспект, що його змінює лише авторитет за правилами, яких у клієнта немає, не можна позначати передбачуваним: це валідаційний збій на деплої, а не несподіванка в рантаймі.

Що оголошують.

ОголошуєЩо це
predictable aspectsякі з них клієнт має право крокувати попереду авторитету
divergence thresholdнижче за нього виправлення згладжують; вище — стан авторитету приймають як є. Оголошений і managed — це не щоденний регулятор дизайнера
display mode for remote entitiesінтерполяція між станами, що прийшли, або екстраполяція
interpolation delayнаскільки показ інших відстає, оголошений, а не підкручений на відчуття
extrapolation windowза ним Entity позначають застарілою, і екстраполяція припиняється
compensation windowнаскільки далеко назад може дістати відмотування, і воно managed
what is rewoundпозиції та орієнтації цілей і геометрія динамічних перешкод, якщо їх оголошено історичними

Що відмотують, а що навмисно ні.

Що це
rewoundпозиції та орієнтації цілей і геометрія динамічних перешкод там, де тип оголошує їх історичними
not rewoundстан життя — мертві не оживають, щоб у них вистрілили — і володіння, рахунок та інвентар
the rule behind the splitрішення ухвалюють у минулому; ефект застосовують у теперішньому
ЗавждиЩо це
authoritative state names the input it sawвін несе номер останнього застосованого вводу, і саме це робить узгодження точним, а не приблизним
divergenceспостережувана: клієнт знає, що його передбачення виправили, замість того щоб тихо пливти
a view timeце заява, а не факт: мить, про яку Actor каже, що бачив її. За межами вікна платформа відмовляє, а не екстраполює: мовчазна екстраполяція — це подарунок читерові, якому достатньо надіслати старіший час
a rewind promises no reproducibility over floating pointте саме обмеження, що й усюди в контракті
one history ring, two consumersі узгодження, і запити з компенсацією лагу читають доріжку миттєвої історії entity. Відмотування належить сюди, а не модулям, що їх відмотують: кільце відновлює пози спірного Tick'а, а далі collision ставлять її звичайне питання про перетин цих поз — вона не тримає власної історії й нічого не знає про «Tick погляду»
tick клієнта:  застосувати ввід локально (передбачити) → покласти в буфер → надіслати зі штампом tick
tick сервера:  крокнути ту саму модель руху → авторитетний стан → Delta назовні
прийом клієнта: авторитетний стан на tick T → якщо розбіжність поза допуском:
                відмотати до T → переграти буферизований ввід T+1..зараз → згладити

Відкочуються лише поля, оголошені передбачуваними; крихітний дрейф згладжують, а справжня розбіжність відмотує і переграє.

Помилки

  • Час погляду за межами вікна — це конфлікт: надішліть поточний. Екстраполювати натомість означало б віддати читерові весь механізм.
  • Запит історії за межами вікна — так само конфлікт.
  • Два дефекти Declaration ловлять на деплої: вікно компенсації, більше за буфер історії, і аспект, позначений передбачуваним, коли в клієнта немає правил, за якими його передбачати. Жоден із них не може дійти до живого матчу.
  • Дві речі не є помилками, і обидві спостережувані. Повний буфер вводу призупиняє передбачення до підтвердження, а не викидає вводи мовчки; а розбіжність понад поріг означає, що стан авторитету приймають як є, і це оголошене виправлення, а не несправність.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Вікно компенсації — за ним відмова, ніколи не екстраполяція.
  • Вікно екстраполяції для інших — Entity позначають застарілою, і екстраполяція припиняється.
  • Буфер непідтверджених вводів — передбачення призупиняють до підтвердження; вводи ніколи не викидають мовчки.
  • Глибина історії для відмотування — не менша за вікно компенсації, і це перевіряють на деплої.
  • Частота дій, що несуть час погляду, — рейт-ліміт зі строком.
  • Одночасно передбачувані entities на клієнта — понад стелю передбачення не виконують, і це оголошена деградація, а не відмова.

Шлях користувача

Постріл під затримкою, розсуджений чесно в часовій лінії стрільця й підтверджений на обох екранах. Снаряд і блок стат жертви — це пресети entity: Declarations на entities, а не модулі, які ви монтуєте.

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

Передбачення власного руху

Це власна машина клієнта, і це єдиний із трьох механізмів передбачення, чиї помилки дешеві. Ви дієте за власним вводом ще до того, як сервер відповів, сервер відповідає, і там, де ці двоє не згодні, ваш клієнт виправляє себе сам. Помилка тут коштує невеликого візуального виправлення — і саме тому тут безпечно бути наполегливим.

Передбачення — це повторення оголошених правил, а не друга копія

Ваш клієнт не виконує паралельну реалізацію вашого руху. Він виконує ту саму оголошену модель, яку виконує платформа, — модель належить Locomotion, а передбачення лише застосовує її раніше. Це і є причина, чому обидві сторони здебільшого згодні: є один набір правил, застосований двічі.

А отже, немає ані операції «передбачити», яку треба викликати, ані операції «виправити». Передбачення стається тому, що аспект оголосили передбачуваним, а не тому, що ви щось викликали.

Що передбачається, оголошують на кожен аспект

Передбачуваність — це Declaration на аспекті entity, і це навмисно не глобальний перемикач:

  • Аспект, який клієнт може обчислити, — позицію під вашим власним вводом, — можна передбачати.
  • Аспект, який авторитет змінює за правилами, яких у клієнта немає, передбачати не можна. Якщо клієнт не може його вивести, здогадка про нього дає відкат, який гравець читає як брехню гри.

Ця лінія — там, де ви вирішуєте, що може мерехтіти, а що має бути правильним з першого разу.

Протокол виправлення і два числа, що надають йому форми

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

Авторитетний стан надходить, несучи номер останнього вводу, який він застосував, тож ваш клієнт точно знає, скільки його власного буфера ще не підтверджено. Далі:

  1. Прийняти авторитетний стан.
  2. Переграти буферизовані вводи, що прийшли після того, який він підтверджує.
  3. Узгодити результат із тим, що ви вже показували.

Два оголошені числа вирішують, як це відчувається. Поріг розбіжності: нижче за нього виправлення згладжується, вище — ваш клієнт стрибає і переграє. І ліміт буфера непідтверджених вводів: переповнення не є невизначеним — деградація оголошена і спостережувана, тож клієнт на поганому зв'язку знає, що він перестав передбачати, а не тихо пливе.

Розбіжність спостережувана для того клієнта, у якого вона була, і лише для нього. Ви можете дізнатися, що ваше передбачення виправили і наскільки, — це корисно для підкручування і для того, щоб показати гравцеві чесний індикатор зв'язку. Прочитати чиюсь чужу розбіжність ви не можете: розмір хибного передбачення — це інформація про їхній зв'язок, а не про гру. Виправлення живе на боці клієнта: стан авторитету — це те, що всім іншим уже показували.

Якщо ви приходите звідкись іще

  • Mover 2.0 в Unreal. Форма знайома: вводи зі штампом Tick'а, модель руху, виправлення від авторитету. Різниця в тому, де живе модель: тут ви її оголошуєте, а платформа її симулює, тож нашого компонента руху, який можна успадкувати чи замінити, не існує.
  • Netcode із відкотом і переграванням, як у Photon Fusion. Перегравання ваших власних непідтверджених вводів після виправлення — той самий механізм, і він тут повністю. Чого тут навмисно немає — це повторне відтворення світу заднім числом; що відбувається натомість і чому, див. у Компенсації лагу.

Чого це не покриває

Entities інших гравців не передбачають, їх відображають — це Показ інших гравців. Розсудити постріл у часовій лінії стрільця — серверний механізм, і він живе в Компенсації лагу. І жоден із трьох не діє взагалі, коли режим авторитету Room'и зовнішній: тоді Tick належить тому, хто його веде, і передбачення теж.

The live game

Показ інших гравців

Ніхто не передбачає інших гравців — їх відображають. Ви отримуєте їхній стан з інтервалами, і щось у цих проміжках вам треба намалювати. Помилка тут нікому не коштує життя; вона коштує видимого ривка, і саме тому вона має власні Declarations, а не ділить їх із Prediction.

Режим показу оголошують, а не вгадують

Для entities, які не ваші, Room оголошує, чим заповнювати проміжок між станами, що приходять: інтерполювати між станами, які у вас є, або екстраполювати за найновіший. Це Declaration на Entity, тож відповідь однакова на кожному клієнті й не пливе разом із тим, хто реалізував рендерер.

Затримку інтерполяції теж оголошують. Показувати інших гравців плавно означає показувати їх трохи пізно, на оголошену величину. Назвати число — це й є суть: неназвана затримка — це баг-репорт, який ви не відтворите, а названа — це дизайнерське рішення, яке ви можете підкрутити під свій жанр.

Екстраполяція зупиняється, а не вигадує

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

Вікно екстраполяції оголошене, і за ним Entity перестає показуватися рухомою, а не їде далі за здогадкою. Екстраполювати нескінченно — це посадити гравця стріляти в ціль, якої там ніколи не було, і помітити це він не може; видиме замерзання — це та поломка, з якої є вихід.

Чому це окремо від передбачення власного

Три механізми передбачення мають різні авторитети й різні режими відмови, і одне слово на всі три означає, що підкручування одного мовчки змінює два інші.

МеханізмВиконується наКоли він хибний
передбачення власногоклієнтівиправлення, перегране і згладжене
показ інших гравцівклієнтівидимий ривок
компенсація лагусерверіхтось помирає несправедливо

І саме тому існує пресет observer, що несе цей механізм і більше нічого: у спостерігача немає власного вводу, який можна передбачати, тож дати йому налаштування передбачення означало б налаштовувати те, чого він не робить.

The live game

Компенсація лагу

Це серверний механізм, і єдиний із трьох, чиї помилки когось убивають. Коли він судить хибно, гравець помирає несправедливо — і на користь того, у кого гірший зв'язок. Усе на цій сторінці має форму, яку йому надала ця асиметрія.

Питання, на яке він відповідає, вузьке: що саме бачив стрілець? Дія може нести час погляду — Tick, на який дивився Actor, коли діяв, — і платформа відновлює пози цілей на цьому Tick'у, тож постріл судять за тим, що було на їхньому екрані.

Час погляду — це твердження, а не факт

Він приходить від клієнта, тож це заява викликача, і з нею поводяться саме так. Два наслідки:

  • Вікно компенсації обмежене, і поза ним платформа відмовляє. Вона не екстраполює, щоб бути корисною. Відмова — це рішення, яке ви бачите; мовчазна екстраполяція — рішення, якого ви не бачите.
  • Читання минулого стану цілі все одно кориться Visibility. Питання про історичний Tick — це не спосіб обійти Visibility: чого ви не могли бачити тоді, того не прочитаєте зараз.

А «влучання не зарахувалося» — це вердикт, а не помилка: успішна відповідь із машинозчитуваною причиною. Ваш код поставив законне питання і дістав законне «ні».

Що відкочується, оголошено, і це не все

Відкочувати все звучить послідовно й дає подвійні вбивства: двоє гравців стріляють одне в одного, обох відмотують до моменту, коли обидва живі, обидва влучають. Не відкочувати нічого скасовує саму компенсацію лагу. Межа між ними — це оголошений список, а не інтуїція реалізації.

Саме відмотування належить сюди, а не модулям, які відмотуються. Кільце історії відновлює пози спірного Tick'а, а далі Collision питають її звичайне питання про перетин цих поз — колізія не тримає власної історії, і ніщо в ній не знає, що таке Tick погляду. Саме кільце — це доріжка історії entity, а не друге сховище.

Рішення ухвалюють на минулому; ефект застосовують у теперішньому

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

Компенсація лагу відповідає на питання про мить погляду стрільця. Наслідки — шкода, смерть, зарахування — застосовуються до поточного стану. Те, що сталося між миттю погляду і миттю рішення, не скасовують і не переобчислюють.

Тож це спостережувано, і так і задумано: гравець може встигнути вистрілити після того, як його вбив чийсь відмотаний постріл. Скасувати це означало б переграти світ поверх відмотування, яке не обіцяє відтворюваності, — а це виготовляє розбіжність замість того, щоб її прибирати.

Пересимуляція на боці сервера навмисно поза межами. Переобчислення наслідків щодо нової правди потребує нерухомої точки відліку, якої стан із рухомою комою нам не дає. Лишається все, на чому модуль стоїть: клієнт, що переграє власні непідтверджені вводи (Передбачення власного руху), і компенсація лагу як читання минулого заради одного рішення. Саме так на практиці працює «на користь стрільця».

Якщо ви приходите звідкись іще

  • Компенсація лагу на користь стрільця, як її постачає більшість змагальних шутерів: той самий механізм, і ця сторінка — про нього.
  • Повний rollback-netcode. Відмотування тут є; перегравання світу після нього — ні, і абзац вище пояснює чому. Якщо ваш дизайн залежить від переобчислення наслідків заднім числом, цю залежність варто підняти з нами рано, а не виявити пізно.

Ще варто знати

  • Реалізацію можна перевизначити. Якщо вашій грі потрібне інше правило компенсації, ви можете замінити наше, а заміна оголошує, яких із Declarations вона дотримується.
  • На шляху передбачення і виправлення точок розширення немає. Вони виконуються з частотою Tick'а, і Hook у цій петлі був би Hook'ом, якого ви не можете собі дозволити.
  • Нічого з цього не діє під зовнішнім авторитетом. Компенсація лагу існує для Rooms, які веде наша симуляція. Коли Tick належить ігровому серверу студії чи master-client'у, компенсація належить тому, хто його веде, — див. Хто веде Tick.
The live game

Bots

Бот входить як звичайний гравець. Деінде живе лише мозок. Та сама сесія, та сама валідація входу, ті самі правила, той самий ACL. Room не бачить різниці, і це задумано, тож боти проганяють ваші справжні правила гри, а античітові ніколи не потрібен виняток для бота.

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

Коли застосовувати

  • Ваші лобі треба наповнювати в години спаду — FillRoom добирає матчі до квоти, а боти поступаються місцями, коли приходять люди.
  • Боти мають грати за справжніми правилами — валідація входу, ACL, visibility, — щоб античітові ніколи не був потрібен виняток для бота.
  • Ви приносите зовнішній мозок — навчену політику, сервіс, — який входить через ConnectAsBot, як будь-який гравець.
  • Entity відпалого гравця має передаватися ботові й назад на перепідключенні так, щоб цього не помітили ані місце, ані prediction.
  • Не потрібно, коли персонаж нічого не вирішує: діалоговий NPC без мозку живе в World Objects.

Ця сторінка — половина про підключення. Як завести бота в Room'у, наповнити лобі до квоти, передати місце між ботом і людиною. Написати те, що вирішує, — це друга половина, Як написати мозок, яка специфікує роз'єм, у який втикається мозок.

Хто що робить

ActorНа цій сторінці
bot-brainпідключається як гравець; отримує сприйняття; надсилає команди
room-ownerоголошує профілі, наповнює Rooms до квоти, передає бота/людину

Одним поглядом

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

Зовнішній мозок (важчий ШІ, навчена політика, сервіс) підключається, як будь-який гравець:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

Модель

Бот не вводить власного поняття — ані учасника, ані каналу вводу, ані зони видимості, ані поведінки. Це облікові дані Actor'а, вдягнені в те, що rooms, data і locomotion уже оголошують.

Що несе Declaration бота.

ОголошуєЩо це
thinking tickяк часто питають мозки, і це не Tick симуляції: мозки виконуються зовні, а мережевий виклик на кожен Tick нездійсненний
direction of the brainsде вони виконуються — хмарна функція, бекенд студії, її ігровий сервер. Який саме — не частина контракту, і перехід між ними не є ламкою зміною
actor presetправа бота, як звичайний пресет Actor'а
visibility of the bot markerчи кажуть про це учасникам. Сам маркер існує завжди, і платформа завжди його спостерігає; чи бачать його гравці — це Declaration типу Room'и, бо на одних ринках розкриття ШІ-суперника є обов'язком, а на інших — продуктовим вибором
behaviour when the brains are unavailableодна з трьох, і без типового значення: do nothing · leave the room · fall back to built-in default behaviour
roster fillingоголошує тип Room'и — скільки, за якої умови, до якої миті. Matchmaking про ботів не знає нічого: він зводить Actors і не вирішує, ким добирати

Що правдиве для кожного бота.

ЗавждиЩо це
an actor, not a playerвін тримає облікові дані Actor'а, але не має ані провайдера входу, ані зв'язків, ані сесій
economic ownershipйого немає — ані прав, ані покупок, ані записів у Leaderboard, — інакше боти опиняються в таблицях і в економіці
perceptionгравцеве: та сама зона видимості, той самий предикат, той самий ліміт кількості об'єктів. Бот і гравець в одній позиції отримують той самий набір об'єктів, тож бот не може бачити крізь стіни так само, як не може гравець
wider perceptionце пресет Actor'а, а не властивість бота: режим налагодження чи «всезнаючого тренера» оголошують пресетом із ширшим предикатом
between thoughtsдіє остання команда, і її доля — це те, що тип руху вже оголошує для застарілого вводу: бот, чий мозок думає, — це той самий випадок, що й гравець, у якого відпала мережа
room capacityвраховує бота: він займає місце, як будь-хто інший

Помилки

  • Room не приймає ботів — це конфлікт, і повтор не допоможе.
  • Ліміт ботів вичерпано — це конфлікт, а не forbidden: дозвіл завести бота є, Room заповнена. Варто повторити, щойно в Room'і звільниться місце.
  • Команда для бота від Actor'а без дозволу відповідає forbidden, і повторювати марно.
  • Недоступність мозків не є помилкою — це одна з трьох оголошених вище поведінок. Чи були вони повільні, лежали чи думали — це справа напрямку, що їх виконує, і це не частина контракту; спостережуване — те, що спостережуване для будь-якого учасника.
  • Оголошено на деплої — відхилено на деплої: бот, названий власником запису в Leaderboard, і відсутній Tick мислення — обидва валідаційні збої на деплої, а не несподіванки в живій Room'і.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Боти в Room'і — заведення відхиляють як конфлікт; наявних ботів ніколи не вилучають, щоб звільнити місце.
  • Боти на проєкт — той самий конфлікт.
  • Tick мислення знизу — Declaration швидше за нижню межу відхиляють на деплої, бо мережевий виклик на кожен Tick нездійсненний.
  • Частота команд для одного бота — рейт-ліміт із часом.
  • Строк відповіді від мозків — щойно він спливає, діє оголошена поведінка на недоступність.

Шлях користувача

Хост добирає лобі до квоти, зовнішній мозок займає одне з місць, і Room увесь час працює за справжніми правилами.

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

Як написати мозок

Мозок — це звичайний код, який відповідає на одне питання: що цей бот робить далі. Він виконується там, де ви захочете, — хмарна функція, ваш власний сервіс, headless-клієнт, — і розмовляє з Room'ою через ту саму поверхню, якою користується клієнт живого гравця. Ця сторінка специфікує роз'єм, у який він втикається: що мозок отримує, що йому дозволено надсилати назад і коли. Підключення бота покриває другу половину: як завести бота в Room'у.

Що вирішено і на чому можна будувати вже сьогодні

Платформа не постачає ігрового ШІ. Ані дерев поведінки, ані utility-системи, ані навігаційного мозку. Це не прогалина, що чекає на заповнення, — це межа. Рішення ваші, а робота модуля — зробити так, щоб ваші рішення не відрізнялися від рішень гравця.

Мозок — не Hook. Hook огортає наш крок. Мозок не є нашим кроком узагалі: він виконується поза Room'ою, за власним розкладом, і платформі байдуже, з якого боку відкрили з'єднання. Саме тому мозок може бути хмарною функцією, сервісом, який хостите ви, або headless-клієнтом — і саме тому жоден із них не рідніший за інші.

Роз'єм — це сприйняття всередину, команди назовні, і обидві сторони навмисно належать гравцеві:

Що це
сприйняттярівно те, що отримував би гравець на цьому місці — ті самі Deltas, крізь ті самі правила visibility. Бот не може бачити крізь стіни так само, як не може гравець.
командирівно те, що надсилав би гравець на цьому місці. Привілейованого каналу вводу не існує.

Якщо грі справді потрібен бот, що бачить більше — режим налагодження, режим тренування, — це оголошене розширення, а не побічний ефект того, що він бот.

Tick мислення оголошений, і це не Tick симуляції. Мозки зовні, тож вони думають у власному ритмі. Між двома думками діє остання команда — і саме це найважливіше, під що треба проєктувати, бо воно означає, що мозок, який думає повільно, дає не бота, який стоїть на місці, а бота, який далі робить те, що вирішив востаннє.

Зникнення мозків має оголошену поведінку, і типового значення немає. Ви кажете, що стається, коли мозок перестає відповідати, для кожного типу Room'и. «Мозки недоступні» — це утримуваний Event, тож той, хто підписався пізно, дізнається про поточну ситуацію, а не лише про майбутні зміни.

Чим бот навмисно не може бути

Варто прочитати, перш ніж проєктувати навколо цього, бо це відмови, а не пропуски.

  • Бот не гравець, і він не володіє правами, покупками чи записами Leaderboard. Бот, який міг би їх тримати, був би способом їх виготовляти.
  • Прапорець бота існує завжди і завжди спостережуваний для платформи. Чи показує його ваша гра гравцям — ваше рішення; чи існує він — ні.
  • Модуль не тримає історії того, що бот вирішив і чому. Це ваша справа, у вашій телеметрії — ми не збираємося ставати місцем, де зберігаються міркування вашого ШІ.
Services around the game

Auth & Players

Вхід — це крок, який можна перевизначити, а не чорна скринька. Провайдери, сесії, зв'язування осіб, бани. Кожна точка потоку — до і після входу, до і після зв'язування, до і після злиття, на зміні статусу — це оголошена точка розширення з оголошеним родом: ворота, які можуть відмовити в кроці, або спостерігач, який не може.

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Гравці мусять входити — пристрій, пошта, Apple, Google, Steam чи власний — зі створенням на першому вході як прапорцем, а не другим потоком.
  • Гостьовий акаунт має пізніше піднятися — Link додає Steam із цілим прогресом, а злиття зводять два акаунти в одного гравця.
  • Політика має виконуватися там, де її не оминеш, — регіональні ворота до входу, стартовий набір після того входу, який створив гравця.
  • Модерації потрібні зуби — відкликати сесії, призупинити, забанити пристрій, з Event'ом banned, який кожна жива система чує одночасно.
  • Оголошений контекст (регіон, платформа, збірка) має доходити до кожного наступного Hook'а без того, щоб кожен перечитував гравця, аби це дізнатися.
  • Нічого легшого, на що можна перескочити, немає — кожен інший модуль називає свого викликача через цей, і auth не можна вимкнути, поки будь-кому з них потрібен Actor-гравець: конфігуратор модулів відмовляє і називає залежних.

Хто що робить

ActorНа цій сторінці
playerвходить, зв'язує чи відв'язує осіб, оновлює, виходить
moderatorвідкликає сесії; банить, призупиняє або відновлює гравців
backend-serviceзакриває вхід за регіоном; засіває перші рядки нового гравця; читає і відкликає сесії

Одним поглядом

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

Кожну точку налаштовують там, де її оголошено; форми, яких може набувати обробник, зібрані в 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.

Оголошена форма — це те, що рендерить панель: кожна точка показує свої обробники, їхній рід і розв'язаний порядок. Рід — це та частина, у якої є зуби:

РідКоли падає сам обробникПри відмові
воротакрок відхиляється: недосяжна перевірка регіону не є пройденою перевіркою регіонукод із каталогу платформи плюс людська причина. Викликачі розгалужуються за кодом; текст причини вільно змінюється і перекладається
спостерігачкрок лишається зробленим, тож стартовий набір, який не застосувався, коштує скрині, а не входувідмовити він не може

Чого не має права робити жоден обробник — це вирішувати, хто увійшов. Ворота відповідають «так» чи «ні» щодо особи, яку платформа вже встановила; вони не називають гравця, не роздають особи й не стоять замість підтвердження провайдера. Ця лінія і є різницею між входом, який можна перевизначити, і входом, який можна оминути.

Модель

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

Гравець — це носій особи, а не рядок у вашій схемі, і його ідентифікатор стабільний і ніколи не перевикористовується — зокрема й на злитті: id злитого гравця далі розв'язується, а не стає висячим посиланням. Зв'язок — це трійка: провайдер · зовнішній суб'єкт · гравець.

ЗавждиЩо це
provider + subjectунікальна, і ця унікальність є джерелом конфлікту, а не заборони: відповідь на «цей акаунт уже зайнято» — це обрати злиття, а не почути «ні»
at most one link per provider per playerдругий акаунт того самого провайдера — це конфлікт
an external subjectніколи не є ідентифікатором гравця: він належить провайдерові, і вживати його як наш означало б прив'язати наші id до їхніх
identity kind and access statusце різні осі: anonymous проти registered — одна; active / suspended / banned — інша. Їхнє змішування не дає виразити «забаненого аноніма» чи «призупиненого зареєстрованого»
a session and a credentialце різні речі: сесія — це запис; облікові дані — це те, що ви пред'являєте. Відкликання сесії робить недійсними всі її облікові дані й закриває її відкриті підписки
many simultaneous sessionsкожну відкликають незалежно
a credential's claimsце оголошений контекст — регіон, локаль — і лише контекст. Твердження ніколи не несе повноважень
a device fingerprintне є особою: він ніколи не є підставою впустити, лише підставою відмовити, і його зберігають та порівнюють у незворотній формі

Три машини.

ЧогоСтани
роду особиanonymous → registered, і перехід односторонній
статусу доступуactive ⇄ suspended, і active → banned → active для розбану
гравцяalive → merged, де merged термінальний: злитий гравець більше не входить

Що оголошує споживач.

ОголошуєЩо це
sign-in policyчи дозволено анонімний вхід і решта правил навколо входу. Оголошується атрибутом на точці монтування модуля — не конфігураційним файлом поруч із кодом і не конструюється в рантаймі
default roleнабір, який новий гравець несе на першому вході. Типового значення для типового немає: не оголосите нічого — і нові гравці приходять без ролей, і це законне Declaration, а не пропуск
session policyщо стається на досягненні стелі одночасних сесій — витіснити найстарішу з Event'ом або відмовити новій. Типового значення немає
deletion policyяк видалення гравця доходить до даних, які на нього посилаються

Де налаштовують провайдера. В операторському плані, а не в коді: обліковим даним магазину не місце в репозиторії. Оголошене доходить до адмінської консолі для читання.

Що робить видача ролі. Ролі — не лише справа оператора: поверхня несе grant і revoke для гравця, тож гра може підвищити офіцера гільдії чи видати хостові турніру його повноваження з власного коду.

ЗавждиЩо це
it is not self-promotionвидача потребує оголошеного для неї атома дозволу, і Actor без цього атома дістає просте forbidden, а не мовчазну бездію
granting is idempotentвидати роль, яку гравець уже тримає, — це успіх, а не конфлікт: станом є набір ролей, а не історія викликів, тож, на відміну від входу, ця операція не потребує ключа ідемпотентності
revoking is not instantі ми не вдаємо, що це так. Воно набуває чинності без перевипуску облікових даних і спостерігається не пізніше за оголошену межу застарілості кеша прав — тож код, що видає роль і одразу перевіряє її на під'єднаному клієнті, мусить проєктуватися навколо цього вікна
the default roleоголошується на проєкт: набір, який новий гравець несе на першому вході. Типового значення для типового немає — не оголосите нічого, і нові гравці приходять узагалі без ролей, і це законне Declaration, а не пропуск

З чого зроблені ролі і що вони відмикають — це Access & Roles.

Помилки

  • Відсутність облікових даних або їхній сплив відповідає not authenticated, і оновлення це лагодить. Відкликані облікові дані відповідають так само, але оновлення не допоможе: лише новий вхід.
  • Ротовані облікові дані, пред'явлені знову, — це конфлікт, і саме це робить ротацію помітною, а не мовчки терпимою.
  • Забанений чи призупинений гравець і забанений відбиток відповідають forbidden, і повторювати марно.
  • Пара провайдер+суб'єкт зайнята — це конфлікт, який можна повторити після вибору злиття; другий акаунт того самого провайдера — це конфлікт, якого повтор не змінить.
  • Відв'язати останній спосіб входу — це валідаційна відмова: це лишило б акаунт, до якого ніхто не дістанеться.
  • Злити вже злитого гравця — це конфлікт: merged термінальний.
  • Недоступність провайдера відповідає unavailable, і варто повторити із затримкою; відхилення облікових даних провайдером відповідає not authenticated, і варто повторити один раз, а не в циклі. Їхнє змішування змусило б клієнтів довбати провайдера, який уже сказав «ні».
  • Перевищена частота спроб входу відповідає в категорії рейт-ліміту зі строком.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Одночасні сесії на гравця — за оголошеною політикою: витіснення найстарішої з Event'ом або відмова новій. Типового значення немає.
  • Спроби входу на період і спроби зв'язати зайняту пару — рейт-ліміт зі строком, а лічильник спроб лишається в історії.
  • Зв'язки на гравця — зв'язати ще одного провайдера відхиляють як конфлікт.
  • Час життя облікових даних — not authenticated, повторно через оновлення. Час життя облікових даних для оновлення — лише новий вхід.
  • Зберігання анонімного гравця без входів — видалення за оголошеною політикою, з Event'ом. Політику оголошують явно; типового значення немає.
  • Записи у списку банів відбитків — додавання відхиляють, а старі записи ніколи не витісняють мовчки.

Шлях користувача

Гостьовий акаунт на першому запуску, піднятий до Steam пізніше з цілим прогресом.

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Profile

Profile — це подання, і платформі не належить майже нічого з нього. Те, що платформа тримає про гравця, — це player_id і системний профіль за ним: особи, сесії, зв'язки з провайдерами, усе це в Auth. Усе, що гравець має, — це ваша власна Entity, яка належить цьому гравцеві. Profile — це набір тих entities, які оголошує ваш проєкт, прочитаний для одного власника за один прохід.

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Екранові потрібен зріз одного гравця одним викликом — оголошений набір розходиться по його власних entities замість того, щоб клієнт зшивав кілька запитів.
  • Поверхні платформи мають показувати людину, а не ідентифікатор: Leaderboard, черга модерації і тікет підтримки тримають player_id і більше нічого, доки проєкт не назве запис, який показує гравця.
  • Іншому гравцеві потрібна картка — те саме читання відносно іншого власника, звужене предикатом рядків і маскою колонок, уже оголошеними в Access.
  • HUD має стежити за власним станом наживо — читання є вибіркою, а вибірка підписується.
  • Не потрібно, коли дані не належать гравцеві: спільні й глобальні рядки — це звичайна вибірка Entity, і власника, від якого розходитися, немає.

Хто що робить

ActorНа цій сторінці
schema-authorпозначає entities як власні гравцеві й оголошує, які з них утворюють Profile
playerчитає власний Profile; записи йдуть у самі entities
room-visitorчитає Profile іншого гравця, наскільки дозволяють предикат і маска того гравця

Одним поглядом

Належність оголошують на кожну Entity, а не на кожне поле. Entity каже, що вона належить Profile; що з неї може бачити інший гравець — це маска колонок на ролі, яка її читає (Access). Атрибут подання на рівні поля був би другою відповіддю на питання, на яке доступ уже відповідає, і ці дві розійшлися б першого ж разу, коли хтось відредагував би одну з них.

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

Читання — це вибірка, обмежена власником: поверхня запитів Entity із зафіксованим власником і списком entities, узятим із Declaration. Profile — це назва того читання, а не модуль, що стоїть за ним: ті самі права, ті самі предикати, ті самі фільтри, та сама підписка, бо це та сама операція.

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

Модель

ПоняттяЩо це
player_idусе, що платформа розуміє під гравцем, плюс системний профіль за ним — Auth
набір Profileвласні гравцеві entities, які проєкт оголошує своїм Profile; оголошувати його необов'язково
вибірка за власникомчитання: на вхід один власник, на вихід його рядки по всьому набору — ті самі права, предикати, фільтри й підписка, що й у будь-якої вибірки Entity
публічне читаннята сама вибірка відносно іншого власника, звужена предикатом рядків і маскою колонок ролі, що читає (Access)

Що правдиве для кожного читання Profile.

ЗавждиЩо це
there is no profile recordу нього немає ані власного ідентифікатора, ані Revision, ані історії, ані життєвого циклу, бо це подання над рядками, у яких є всі чотири
writes go where the data livesправте рядок progress — і кожне читання Profile, яке його включає, побачить нове значення на наступному проході
there is no public writeподанню нема в що писати, а спільно записуваний стан іде через серверний код
declaring the set is optionalі не оголосити його — це не те саме, що оголосити порожній: у проєкті без набору Profile читання Profile немає взагалі, і виклик відхиляють як недоступний, тоді як порожній результат сказав би, що Profile у гравця є і він просто порожній
ownership is a predicateа не колонка, яку додає платформа (Access): owner == caller.player — це один випадок механізму, «один із учасників» — інший
a derived field belongs to a hookсаме спостерігач після зміни штампує Progress.Title, коли Level перетинає поріг. Він іде за записом; відмовити в ньому він не може

Помилки

  • У проєкті без оголошеного набору Profile читання Profile немає, і виклик відхиляють як unavailable, а не відповідають порожнім результатом, який сказав би, що Profile у гравця є і він просто порожній.
  • Рядок, схований предикатом, відповідає not found, і той, якого не існує, теж: публічне читання ніколи не стає способом дізнатися, що існує, але не видно.
  • Поле поза маскою ролі, що читає, відсутнє у відповіді, а не присутнє і порожнє.
  • Публічного запису немає. Поданню нема в що писати, тож спільно записуваний стан іде через серверний код, а не через цю поверхню.
  • Запис від імені гравця, що не називає гравця, — це валідаційна відмова.
  • Оголошення набору Profile — схемний акт: fn або adm. Ключу гравця, який на це піде, відповідають forbidden, і push відхиляється цілком, а не оголошується наполовину.

Обмеження

Кожна стеля називає свою поведінку на краю; числа надійдуть із розділом про обмеження платформи.

  • Розмір сторінки вибірки за власником — підрізається до стелі, а прапорець «є ще» лишається істинним; повернути менше без прапорця заборонено.
  • Розмір включеного набору на рядок — підрізається за тим самим правилом, із прапорцем на включенні.
  • Частота змін одного екземпляра — відмова рейт-ліміту зі строком; читання Profile — це вибірка, як будь-яка інша, і воно успадковує стелі entity, а не оголошує власні.

Шлях користувача

Від малювання лобі до перескоку рівня: серверний запис доходить до підписаного екрана, і екранові не треба питати знову.

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Social

Одне нове поняття, а все інше побудоване з того, що у вас уже є. Стосунок двох Actors, з власним станом і ініціатором, — це все, що додає цей модуль. Клан, гільдія чи загін — це group з шаром стосунків над нею, а не другий рід речей; а блокування, яке потрібне кільком модулям, живе тут, щоб ним володіло рівно одне місце.

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Гравці потрібні одне одному на ім'я — друзі, підписники, списки блокування.
  • Кланові чи гільдії потрібні двері — запрошення від Group, заявка на вступ від Actor'а і рішення щодо кожного.
  • Список друзів має показувати, хто онлайн, — присутність виводять із сесій, а хто може її бачити — це предикат, який оголошуєте ви.
  • Іншому модулеві треба знати, що когось заблоковано, — він читає цей стан звідси, а не тримає власного.
  • Не потрібно, коли річ — це набір Actors, а не пара зі станом: це group, а Group на пару означала б мільйони груп із двох осіб, кожна з власним життєвим циклом і правилами входу.

Хто що робить

ActorМожеНе може
playerзапропонувати стосунок або підписатися; прийняти, відхилити чи відкликати; розірвати взаємний; заблокувати і розблокувати; читати власні стосунки і присутність пов'язаних Actors; підписуватися на зміни; подати заявку на вступчитати чийсь чужий список стосунків, за будь-якого стосунку учасників узагалі
moderatorвирішувати щодо запрошень і заявок на вступ там, де він тримає атом адміністрування членствавирішувати щодо наміру, на який у нього немає дозволу, — на це відповідають forbidden

Модель

Що несе Declaration стосунку.

ОголошуєЩо це
kindсиметричний — парі потрібна згода обох сторін, і машина станів нижче саме про нього; або односторонній — підписка, чий єдиний стан active. Унікальність на пару й ідемпотентність пропозиції діють для обох
re-invitation ruleпісля відмови: заборонено · дозволено після оголошеного періоду · дозволено одразу. Оголошується, бо «спитати ще раз» — це продуктове рішення
presence visibilityпредикат — усім · лише взаємно пов'язаним · нікому. Типового значення немає
joining mode (на типі Group)відкритий · за заявкою з рішенням · лише за запрошенням
retention of declined and brokenпісля оголошеного періоду стосунок прибирають, і повторне запрошення знову стає можливим незалежно від правила повторного запрошення

Стани симетричного стосунку.

СтанЗначення
proposedініціатор запропонував, а інша сторона не відповіла
mutualобидві сторони згодні
declinedінша сторона відмовила. Стосунок зберігають, бо правилу повторного запрошення треба це знати
brokenодна сторона вийшла зі взаємного стосунку
blockedодна сторона заблокувала другу

Що правдиве для кожного стосунку.

ЗавждиЩо це
one entity per pairне два дзеркальні записи. «A запропонував B» і «B отримав пропозицію від A» — це один факт, прочитаний з двох боків
an initiatorоголошений: хто запропонував, а це потрібно і показу, і правилу повторного запрошення
blocked dominatesз нього немає переходу в proposed чи mutual
a blockасиметричне в керуванні, симетричне в дії: зняти його може лише той, хто поставив, а діє воно в обидва боки
a refusal on a blockйого не розкриває: операція відповідає not found, тож заблокований Actor не може виявити блокування, промацуючи
the block stateним володіють тут, а споживають деінде: messaging та інші його читають; жоден із них його не змінює і жоден не тримає копії
presenceвиводиться із сесій: її ніхто не пише, а предикат видимості застосовують до кожного, хто питає, а не один раз на Actor'а
a deferred intentне займає місця: запрошення чи заявка на вступ ніколи не зараховуються до місткості Group — інакше сотня заявок вичерпує клан на п'ятдесят, і ніхто не може ввійти
a groupтримає адміністратора: щонайменше один Actor мусить тримати атом адміністрування членства, і останній не може просто піти: клан, чий останній адміністратор вийшов, ніколи більше не зміг би нікого впустити
no intra-group roles«офіцер клану» — це Actor, що тримає атом, а не звання, збережене у списку
an import never overwritesстосунки, принесені від провайдера входу, додаткові: заблокований не стає другом лише тому, що так каже провайдер

Помилки

  • Уже взаємний — це конфлікт; пропонувати нема чого.
  • Пропозиція самому собі — це валідаційна відмова.
  • Одна зі сторін заблокувала — відповідь not found, а не forbidden, бо відмова, яка їх розрізняла б, розкрила б блокування. Повторювати марно.
  • Повторне запрошення до строку — це конфлікт, який варто повторити після нього.
  • Вичерпаний ліміт — стосунків, намірів — це конфлікт, а не forbidden: дозвіл є, місця немає. Повторюйте, коли одне звільниться або коли наявні наміри буде вирішено.
  • Намір, що сплив, — це конфлікт: створіть новий, а не повторюйте старий.
  • Вихід останнього адміністратора Group — це конфлікт, доки дозвіл не передано далі.
  • Рішення щодо чужого наміру без дозволу відповідає forbidden, і повторювати марно.
  • Імпорт від провайдера, який не підключений, відповідає unavailable — повторюйте з наростаючою затримкою.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Взаємні стосунки на Actor'а — пропозицію відхиляють як конфлікт; наявні ніколи не розривають, щоб звільнити місце.
  • Односторонні стосунки на Actor'а — новий відхиляють; наявні лишаються.
  • Вихідні пропозиції — нову відхиляють, і витіснення немає: витіснене запрошення не відрізнялося б від відхиленого.
  • Блокування на Actor'а — додавання відхиляють як конфлікт, а старіші блокування не витісняють: мовчки розблокований починає писати знову, і ніхто не знає, чому.
  • Час життя наміру — expired, з Event'ом.
  • Частота пропозицій на Actor'а — рейт-ліміт із часом.
  • Частота змін присутності в потоці — обмежена частотою оновлення, а не викиданням змін.
  • Зберігання відхилених і розірваних стосунків — прибирання за оголошеним періодом.

Шлях користувача

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Messaging

Rooms, Groups, гравці: одна модель адресації для чату і сповіщень. Повідомлення приходять у розмову; розмови — це channels з історією, модерацією і позасмуговою доставкою поверх.

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Гравці розмовляють — чат Room'и, канали гільдії, приватні повідомлення — поверх адресації, яка у вас уже є: Room, group, гравець.
  • Офлайн-гравці все одно мають почути — шаблонні сповіщення з розкладом доставляються позасмугово пушем.
  • Модерація має відпрацювати до доставки — Hook перед надсиланням фільтрує або відхиляє, а заглушення/блокування скрізь забезпечує платформа.
  • Гравцям, що повертаються, потрібне надолуження — History(take: 50) читає розмову сторінками на наступному запуску.
  • Не потрібно, коли payload — це ігровий стан, а не розмова: синхронізовані поля в Data і channels core це вже розсилають.

Хто що робить

ActorНа цій сторінці
playerнадсилає й отримує повідомлення; читає історію; глушить або блокує
moderatorфільтрує, маскує і банить терміни
backend-serviceнадсилає або планує шаблонні сповіщення

Одним поглядом

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

Структуроване повідомлення — це оголошений Event, і розмова далі несе його за іменем — жодного класу payload'а конструювати на місці виклику не треба:

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

Оголошене повідомлення приходить типізованим у тій самій підписці, тож клієнт, який знає RallyCall, дістає поля, а не блоб.

Сповіщення позасмугові, шаблонні й плановані — і надсилають їх з авторитету fn або adm, ніколи з сесії гравця:

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

Модерація як Hooks, той самий контракт, що й усюди:

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Модель

Що оголошує тип розмови.

ОголошуєЩо це
the groupїї склад — учасник — це Actor, рівно як у groups
binding to a lifetimeза бажанням — до часу життя іншої Entity, тож чат Room'и зникає разом зі своєю Room'ою

Де проходить лінія між конвертом і payload'ом.

ЧастинаЧия вона
envelopeплатформи: автор, розмова, мить за оголошеним годинником
payloadстудії, оголошений як тип повідомлення з типізованими полями і поданий власною поверхнею надсилання, а не нетипізованим мішком

Три речі, які належать цьому модулю і яких у звичайного Event'а немає, — порядок усередині розмови, період зберігання і предмет модерації. Саме тому чат не є «Event'ом з історією»: порядок, історія й модерація для гравця тут є справою платформи і їх немає в events.

Три машини.

ЧогоСтани
розмовиcreated → active → closed
повідомленняsent → published | rejected by the filter, а далі відредаговане чи видалене, спостережувано
сповіщенняcreated → queued → delivered | expired

Що правдиве для кожного повідомлення.

ЗавждиЩо це
order within a conversationстабільний і оголошений. Порядок між розмовами не обіцяють
editing and deletingспостережувані: повідомлення ніколи не зникає мовчки — інакше історія клієнта і серверна розходяться, і ніхто про це не знає
historyце самі повідомлення: з оголошеним періодом зберігання, читаються сторінками за курсором від позиції
retention outlives the complaint windowперіод не коротший за час, відведений на розгляд скарги: скарга приходить після повідомлення, а повідомлення, якого вже немає, не лишає нічого для розгляду
read stateце позиція, а не прапорець: одна позиція на Actor'а на розмову, а позначення прочитаним монотонне — позиція ніколи не зменшується, тож повторний виклик не може відкотити прогрес. Кількість непрочитаних — похідна від цієї позиції, а не власний лічильник
sendingідемпотентне за ключем: два виклики — це дві репліки діалогу, тож саме ключ робить повтор безпечним
blockingце предикат доставки, а не відмова надіслати: відправникові не кажуть, бо відмова розкрила б блокування. Сам стан живе в social
the sender composes the payloadплатформа не читає даних отримувача, щоб заповнити ваш текст. Локаль отримувача може бути оголошеним твердженням контексту, що доходить до точки розширення, тож підстановка й переклад — робота Hook'а, єдиного місця, яке знає і отримувача, і його локаль
the delivery routeне є частиною контракту: push, всередині застосунку чи щось інше — це рішення маршрутизації, а не обіцянка
deliveryспостережувана в оголошених межах: «в черзі» завжди; усе понад це — настільки, наскільки маршрут може відзвітувати

Кожна точка розширення називає тип, який вона передає Hook'ові: вихідне повідомлення до публікації, опубліковане після. Фільтр може виправити вміст, який йому дали, — маскування слова є виправленням, — але ніколи не відправника і не розмову.

Помилки

  • Розмова, якої не існує або яку сховано, і Actor, який не є учасником, обидва відповідають not found — тож відмова ніколи не розкриває розмови, в якій вас немає.
  • Закрита розмова — це конфлікт.
  • Немає дозволу писати в цьому типі — відповідь forbidden, і повторювати марно.
  • Відхилення фільтром — це вердикт, а не відмова. Виклик виконано, вміст розглянуто, рішення негативне, а причина — оголошене значення, і саме тому воно відрізняється від відмови за дозволами і саме тому те, що робити далі, залежить від причини.
  • Недоступність фільтра відповідає unavailable, і варто повторити з наростаючою затримкою, — але тим часом нічого не опубліковано.
  • Неоголошений тип повідомлення для цієї розмови і завелике повідомлення — це валідаційні відмови; вміст ніколи не обрізають мовчки.
  • Перевищена частота надсилання відповідає в категорії рейт-ліміту зі строком.
  • Правка чужого повідомлення відповідає forbidden.
  • Сповіщення після спливу свого строку — це конфлікт: надішліть нове.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Розмір повідомлення і вкладення з їхнім розміром — надсилання відхиляють як валідаційний збій, ніколи не обрізають. Самі файли належать Files & UGC.
  • Частота надсилання на Actor'а — рейт-ліміт зі строком.
  • Глибина історії — після періоду повідомлення витісняють зі зберігання з Event'ом, а не дають йому тихо зникнути.
  • Розмови на Actor'а — приєднання до ще однієї відхиляють як конфлікт.
  • Сповіщення в черзі на Actor'а — нове відхиляють, і витіснення заборонене: мовчки викинуте сповіщення не відрізняється від того, яке ніколи не надсилали.
  • Сплив строку сповіщення — expired, з Event'ом.
  • Учасники в розмові — це ліміт groups, а блокування на Actor'а — ліміт social; жодного з них тут не переказують.

Шлях користувача

Один клич доходить до всієї гільдії. Доставку ділять дві ролі: online-member, який у розмові, коли той надходить, і offline-member, який дістає пуш і читає клич з історії на наступному запуску.

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Catalog & Commerce

Предмети, ціни, гаманці, вітрини, покупки, права. Справжні інтеграції з магазинами там, де платформи це дозволяють (Stripe, App Store, Google Play, Steam, Xbox); вітрини з розкладом і націленням на аудиторію; і потік покупки, на кожен крок якого можна поставити Hook.

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Ви щось продаєте — за справжні гроші через Stripe, App Store, Google Play, Steam чи Xbox або за валюту гаманця.
  • Вітрини мають розв'язуватися на кожного гравця — розклад, аудиторія і ціна обчислюються на сервері, а не арифметикою прийнятності в клієнті.
  • Правила ціноутворення належать одному Hook'у, який можна протестувати, — знижки, переоцінка і вето виконуються до будь-якого списання.
  • Чеки мають бути стійкими до повторного програвання, а повернення має відкликати право тими самими Events, якими користувалася видача.
  • Не потрібно, коли предмети ніколи не продають, — хоча нагороди все одно надходять через один Grant комерції з походженням reward (циклові скрині Leaderboards з'являються саме так), тож навіть гра без магазину тримає єдиний реєстр видач, придатний для аудиту.

Хто що робить

ActorНа цій сторінці
playerпереглядає вітрини, купує, керує гаманцем, погашає коди
sellerналаштовує каталог, ціни і розклади вітрин
backend-serviceвалідує чеки; переоцінює або видає через Hooks покупки

Одним поглядом

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

Модель

Що оголошує предмет каталогу.

ОголошуєЩо це
keyце авторський контент, адресований ключем, тож перейменування в коді є перейменуванням
kindconsumable — його витрачають; або durable — ним володіють один раз
pricesціна — це грошова величина: ціле число в мінорних одиницях плюс код валюти, ніколи не число з рухомою комою. Предмет може нести кілька — і ігрову валюту, і справжню
external identifier per providerодин слот на провайдера, оголошений, бо магазин знає предмет за власним id
what it points atза бажанням, Entity будь-якого оголошеного роду, і тоді купівля предмета дає володіння цією Entity

Чим композиції дозволено бути.

Що це
what is purchasableпредмет каталогу, ніколи довільна Entity: ціна, яку ніхто не обслуговує, не є обіцянкою, бо для покупки потрібен той, хто дає право і відповідає за повернення
two levels, no thirdнабір — це предмет каталогу, зроблений із предметів; вітрина — це набір пропозицій, а пропозиція вказує на предмет і може перевизначити його ціну і вміст набору
a territorial priceвиражається вітриною, а не на предметі

Що оголошує вітрина.

ОголошуєЩо це
offersнабір, кожна вказує на предмет
scheduleза стінним годинником, завжди UTC: коли вікно відкривається і закривається
audienceпредикат, а не список гравців, — тож аудиторія — це правило, яке лишається істинним, а не знімок

Вітрина, в чию аудиторію гравець не потрапляє, для нього не існує.

Стани замовлення.

ЗУ
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid або grantedrefunded
ЗавждиЩо це
the priceфіксується в замовленні тієї миті, коли його створюють, тож зміна ціни після цього не може змінити домовленого
awaiting paymentмає оголошений строк, оголошений на кожного провайдера, бо вони різні
grantingвідокремлене від оплати: paid і granted — різні стани: прихід грошей і поява речі — це два факти, і їхнє злиття ховає, який із них упав
a refundце зовнішній перехід: воно приходить без жодного запиту від нас, будь-коли, і що стається з виданим, оголошено — є три відповіді і немає типової
an entitlementнесе своє походження — покупка, промокод, нагорода, подарунок, — тож на «звідки це взялося» можна відповісти через рік
a consumable entitlementнакопичується: воно змінюється інкрементом із ключем ідемпотентності, ніколи перезаписом прочитаного
ownershipце предикат власника: право належить гравцеві тим самим механізмом, що й будь-який власний рядок
the catalogоголошують у коді, і він доходить до панелі типово в режимі володіння seed: код створює те, чого немає, а правки дизайнера переживають наступний push
provider secretsживуть в операторському плані, ніколи в Declaration і ніколи в репозиторії
a provider's capabilitiesоголошені: чи є в нього придатне API взагалі і що воно вміє, — щоб каталог не обіцяв потоку, якого магазин не обслужить

Кожна точка розширення називає тип, який вона передає Hook'ові: намір покупки до покупки — гравець, пропозиція, провайдер, ціна — і саму покупку після. Hook ніколи не отримує нетипізованого мішка.

Помилки

  • Поза аудиторією відповідає not found, і повторювати марно. Поза розкладом теж відповідає not found, але варто повторити, коли вікно відкриється.
  • Провайдер недоступний і провайдер відхилив платіж — навмисно різні відповіді: перша — unavailable і повторна з відступом, друга — конфлікт, якого повтор не полагодить. Їхнє злиття змусило б викликачів повторювати відмову вічно.
  • Недійсний чек — це валідаційна відмова; чек, уже спожитий іншим замовленням чи іншим гравцем, — це конфлікт, і саме це робить повторне програвання марним.
  • Ціна змінилася між читанням вітрини і купівлею — це збій передумови: перечитайте і вирішіть знову, а не платіть мовчки нову ціну.
  • Нестача ігрової валюти — це конфлікт, а не forbidden: дозвіл купувати є, балансу немає. Варто повторити після поповнення.
  • Довговічне право, яке вже тримають, — це конфлікт.
  • Регіон чи вік не дозволяють покупки — відповідь forbidden, і повторювати марно.
  • Строк замовлення минув — це конфлікт: створіть нове замовлення.
  • Вичерпаний ліміт витрат відповідає конфліктом або рейт-лімітом залежно від того, який це був ліміт, і каже, коли ліміт скидається.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Розмір каталогу — опублікувати ще один предмет відхиляють як конфлікт.
  • Вітрини на проєкт — створення відхиляють.
  • Пропозиції у вітрині — додавання відхиляють; вітрину ніколи не обрізають мовчки.
  • Час життя замовлення, що чекає оплати, — перехід у expired, з Event'ом.
  • Частота спроб покупки — рейт-ліміт зі строком.
  • Ліміт витрат на період — конфлікт, який каже, коли ліміт скидається.
  • Зберігання замовлень — після періоду замовлення стає недоступним для читання за оголошений період, а не зникає без пояснень.
  • Права на гравця — видачу відхиляють, а вже видані ніколи не витісняють.
  • Точність ціни — це не ліміт, а тип — ціле число в мінорних одиницях.

Шлях користувача

Перша покупка нового гравця: вітрина розв'язується, ціна ділиться навпіл, предмет надходить — і продаж доходить до воронки першої покупки, яку operator читає в analytics.

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Inventory

Тут інтегрується все. Постріли списують набої, дроп потрапляє сюди, здібності це перевіряють, рух цим модифікується — один власний набір рядків, зі стеками, що інкрементуються, і стелею на власника, чию поведінку на краю обираєте ви.

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

Коли застосовувати

  • Гравці щось тримають, і володіння — це рядок із власником: читається за власником, обмежується на власника, з оголошеною, а не типовою поведінкою на переповненні.
  • Кількість накопичується — стек змінюється інкрементом із ключем ідемпотентності, тож повторене списання не списує двічі.
  • Інші модулі витрачають з одного набору — постріли списують набої, дроп видає здобич, покупки з'являються рядками відповідно до свого права.
  • Не потрібно, коли число не можна тримати у власності — hp, xp і кулдауни належать статам.

Хто що робить

ActorНа цій сторінці
playerчитає власні володіння і витрачає з них
backend-serviceвидає, інкрементує і відкликає від імені гравця, називаючи гравця, за якого діє

Одним поглядом

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

Клієнтська сесія гравця виконує читання, переміщення і перевірку достатності тими самими викликами. Видача, споживання і знищення — не її справа: платформа відмовляє в них як у заборонених і називає право, якого викликачеві бракує, хоч би яка прив'язка зробила виклик.

Модель

Inventory не вводить власних понять. Це пресет — форма, зібрана з того, що entity вже дає, тож усе нижче — це Declaration на Entity, а не механізм цієї сторінки. Пресет, якому знадобився б новий рід Declaration, був би прогалиною в контракті, а не приводом розширити пресет.

ОголошуєЩо це
власний типволодіння належить власникові, а вибірка за власником — власна операція Entity
ref на предмет каталогупосилання зберігає id предмета і ніколи його key, і саме це робить перейменування ключа безпечним. Саме визначення живе в commerce
аспект стека з інкрементомстек змінюється Delta, а не перезаписом прочитаного. Інкремент за природою не ідемпотентний — два інкременти це два інкременти, — тож він зобов'язаний приймати ключ ідемпотентності, а спосіб установити результат — адресоване читання
стеля на власника з поведінкою на межіодна з трьох, і типового значення немає: refuse · redirect в оголошене відро власника · discard with event

Що правдиве для кожного володіння.

ЗавждиЩо це
the cap has no defaultтри відповіді на повну торбу — це три різні ігри: відмова втрачає здобич на очах у гравця, перенаправлення — це пошта або склад, що переповнюється, відкидання — тиха втрата, законна лише тому, що її оголошено і вона спостережувана. Жодна не є правильною для всіх трьох, тож обирає Declaration
the owner is immutableніщо не переходить із рук у руки правкою поля: володіння переміщується як відкликання плюс нова видача з оголошеним походженням, і обидва факти лишаються в записі. Правка власника натомість стерла б слід, лишивши «звідки це в мене» і «це в мене забрали» без нічого за поточним станом
a transfer between two playersце інша обіцянка: вона потребує ескроу й антифроду і лишається поза цією версією
a rowпоказує право, а не є його другим джерелом: куплене живе в commerce, а рядок тут його представляє

Помилки

  • Екземпляра не існує або його ховає предикат — відповідь not found в обох випадках, тож відмова ніколи не розкриває, що щось існує, але не ваше.
  • Поле, не оголошене в аспекті (включно з вкладеними), і обов'язкове поле без значення — це валідаційні відмови, які називають поле.
  • Версія не збіглася — це збій передумови, варто повторити після повторного читання.
  • Запис від імені гравця, що не називає гравця, — це валідаційна відмова, а не мовчазний запис від чужого імені.
  • Немає дозволу на читання чи запис — відповідь forbidden, і читання, й запис розрізняють.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Екземпляри на власника — за оголошеним правилом вище, і типового значення немає.
  • Розмір збереженого екземпляра — запис відхиляється як конфлікт, і відмова називає поле-винуватця й виміряний розмір. Стелі досягають накопиченням, тож наближення до неї спостережуване до того запису, який упаде.
  • Частота змін одного екземпляра — відмова рейт-ліміту зі строком.
  • Розмір сторінки вибірки — сторінку підрізають до стелі, а прапорець «є ще» лишається істинним; повернути менше без прапорця заборонено.

Шлях користувача

Набої одного пострілу, від застосування, що їх списує, до дропу з ящика, що видає їх назад. Здібність, снаряд, блок стат ящика і таблиця дропу в ньому — це пресети entity: Declarations на entities, а не власні модулі.

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Leaderboards

Кожна механіка, систематизована. Не каталог типів таблиць. Одна модель, чиї осі складаються в усі з них: денні таблиці, таблиці кращого кола, суми гільдій, сезони, турніри.

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

Читайте той блок так: хто діє на цій сторінці (actors), що модуль вам дає (provides), на яких модулях він стоїть (builds-on) і де він висить відносно кореня — mounts: root означає playserv.Leaderboards, а не простір імен під іншим модулем (як монтуються модулі).

Коли застосовувати

  • Рахунки мають ранжувати гравців — денні таблиці, таблиці кращого кола, суми гільдій — однією оголошеною моделлю, а не системою на кожну таблицю.
  • Вам потрібні стандартні читання — топ-N, навколо-мене, іменований список власників — без додаткового моделювання даних.
  • Цикли мають закриватися за розкладом, архівуватися (ніколи не видалятися) і запускати Hook нагороди з фінальною таблицею.
  • Підозрілі рахунки ніколи не мають потрапляти в таблицю — передвідправний Hook валідує, зрізає або відхиляє з типізованою причиною.
  • Турнір — це та сама таблиця з вікном вступу, максимумом учасників і спробами на цикл.
  • Не потрібно, коли число ніколи не порівнюють між гравцями: особистий лічильник чи кар'єрна сума — це звичайні дані. Модуль упорядковує результати; він ніколи їх не обчислює і не веде сітки на вибування.

Хто що робить

ActorНа цій сторінці
playerчитає топ-N/навколо-мене/власний ранг, підписується на зміни рангу
backend-serviceвідправляє результати; виправляє чи відхиляє їх у передвідправному Hook'у; видає нагороди, коли цикл закривається
operatorоголошує таблиці; закриває цикл раніше, виправляє записи (з аудитом), стежить за частотами відправок

Одним поглядом

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

Declaration живе поруч із рештою вашої схеми — у серверному проєкті або в проєкті UE чи Unity, — і playserv push компілює його й відправляє нагору: таблиця з'являється в панелі, порожня, з уже запланованим наступним скиданням. Розклади в UTC, тож ця таблиця закривається в понеділок 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") пересуває годину. Місцевий час кожного гравця не є варіантом скидання — одна таблиця не може закритися у двадцять чотири різні миті.

Ключ порядку — це список, а не рахунок плюс розв'язання нічиї. Поля ранжують у тому порядку, у якому ви їх пронумерували, кожне зі своїм напрямком, а останній ярус належить платформі: за рівних ключів вище стоїть раніша відправка, тож два однакові забіги ніколи не міняються місцями між двома читаннями. Поле поза ключем — тут Map — зберігається для показу і ніколи не рухає рядка.

Agg каже, що робить друга відправка з тим одним записом, який має власник у поточному циклі:

AggДруга відправкаІдемпотентно
Setзаміняє запис відправленими значеннямитак
Bestзаміняє його лише тоді, коли нові значення стоять вище за ключем порядкутак
Incrementдодає відправлені значення до запису — вбивства, кола, внесок у гільдіюні — передавайте ключ ідемпотентності
Decrementвіднімає їхні — передавайте ключ ідемпотентності

Відправка, що не перебиває запис Best, не є помилкою: вона повертається прийнятою, з незмінним порядком. Increment і Decrement — це ті два, які повторений виклик застосував би двічі, тож вони беруть той самий ключ ідемпотентності, що й будь-який інший повторюваний запис.

Відправка — це один виклик, і на цій таблиці вона приходить із серверного коду, бо так сказало Declaration:

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

Три речі в цьому виклику варто прочитати окремо:

У викликуЩо це
PlayServ · playservхендл хмарної функції і клієнтський екземпляр, який SDK видає вам на старті. Той самий API, два викликачі — у Go це ps і psv, і кожен сніпет користується тим, що є в його викликача
відправникфункція, якій належить результат матчу. У Tanks це Hook on dispose у Room'и (Rooms), що виконується з фінальним станом на руках
playerIdплатформний id гравця з Auth & Players, а не ім'я, яке обрали ви: Hook читає його зі свого payload (e.By.PlayerId в уроці), а хост Room'и надсилає id того місця, яким володіє

Значення — це поля, названі Declaration: неоголошене поле відхиляється, а не зберігається.

Читання, потрібні кожній грі, і підписка, що тримає їх актуальними:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) — це вікно за рангом, а не сторінка: п'ять рядків вище за вас, п'ять нижче, плюс власний — одинадцять рядків, підрізаних симетрично там, де таблиця закінчується, тож ранг 2 дістає коротше вікно з обох боків, а не зсунуте. Top розбитий на сторінки: він повертає перші N рядків і курсор, а after: проходить решту.

ForOwners — це те, як працює таблиця друзів. Платформа не тримає графа друзів; ви передаєте власників, які у вашої гри вже є, — членів group або список id із ваших власних даних, — і кожен рядок повертається зі своїм рангом у повній таблиці, а не з рангом усередині списку.

OnRankChanged доставляє власний ранг локального гравця і більше нічого: таблиця з п'ятдесятьма тисячами учасників не надсилає кожне перетасовування кожному клієнтові. Зворотний виклик отримує змінений рядок — ранг, ранжовані поля, поля показу, — а Cancel() закінчує підписку. Сам ранг — це знімок: два читання з інтервалом у секунду можуть різнитися, поки надходять відправки, хоча ваша власна відправка завжди видима вашому власному наступному читанню.

Модель

Що оголошує таблиця.

ВісьЗначенняЯк ви це задаєте
Власникгравець · groupOwner = Owner.Player — таблиця гільдій — це та сама таблиця з Owner.Group
Ключ порядкуодне чи більше оголошених полів, кожне за зростанням або спаданням[Rank(1, Sort.Descending)] int Score
Агрегаціяset · best · increment · decrementAgg = Aggregation.Best
Скиданнярозклад у UTC; цикл спливає, ніколи не видаляєтьсяReset = Reset.Weekly(DayOfWeek.Monday)
Хто має право відправлятилише сервер (типово) · гравціSubmit = Submit.ServerOnly
Поля показуоголошені й типізовані; ніколи не частина порядку[Display] string Map
Список власниківобирається під час читання, а не оголошуєтьсяForOwners("weekly-score", ids) — друзі, гільдія, лобі
Турнірні правилавікно вступу · максимум учасників · спроби на цикл · вимога вступуRules = Tournament.Define(…), у таблиці під Турнірами

Осі області немає: таблиця на регіон, на Room'у чи на сезон — це таблиця на ключ, а саме ключ і є тим, на що посилається ваш код.

Що правдиве для кожної таблиці.

ЗавждиЩо це
direction and operatorнезмінні після першого запису: їхня зміна мовчки переранжувала б історію, а спосіб змінити механіку — це нове покоління, а не правка
exactly one entry per owner per generationдругий не є другим рядком
an entryне є Entity: ані власного життєвого циклу, ані машини — його створює перша відправка і змінює оператор, якого оголосила таблиця
fields outside the order key never affect the orderвони для показу, і саме тому їх оголошують окремо
a generationспливає, а не видаляється: open → expired → evicted from retention, і покоління, що спливли, лишаються доступними для читання протягом оголошеного періоду зберігання
the schedule transitionспостережуваний Event'ом, тож обробник читає рівно ту таблицю, яка закрилася, а не порожню, яка щойно відкрилася
the default submitterце сервер: хто має право відправляти, оголошують, і типове значення — не гравець
a boardце авторський контент: оголошена в коді, адресована за key, доходить до адмінської консолі, у режимі володіння seed, тож правки розкладу від дизайнера переживають наступний push
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

Чим є цикл і що робить його закриття.

Що це
a resetзакриває цикл, а не видаляє його
a closed cycleперестає приймати відправки і лишається доступним для читання під своєю міткою — Top("weekly-score", 100, cycle: label), параметр читання, а не задача експорту
the close eventнесе цю мітку, тож обробник читає рівно ту таблицю, яка закрилася, а не порожню, яка щойно відкрилася

Два Hook'и на таблиці, і їхні види різні.

HookЩо він може
pre-submitgatekeeper: платформа викликає його і чекає. Він може виправити відправлені значення відносно ваших власних entities, зрізати їх або відхилити з типізованою причиною, а якщо він дає збій, то відправку відхиляють — fail-closed. Він не може змінити ані власника запису, ані його таблицю: вони вже заявлені. Він повертає вердикт — прийняти, прийняти виправлену відправку або відхилити, — і відхилення доходить до викликача типізованою проблемою (Core), у тій самій формі, якої набуває кожна відмова в SDK
cycle-closedobserver: спрацьовує постфактум, накласти вето не може, і збій там лишає цикл закритим
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

Нагорода — це видача Commerce, а не механіка цього модуля: chest.gold — це id каталогу, а походження reward — це те, що відділяє видачу від покупки: повернення, відкликання і Event зміни прав працюють з нею рівно так само, як із купленим предметом.

Турніри. Турнір — це ця таблиця плюс обмеження участі: другого механізму немає, і окремої Entity теж. Чотири обмеження, з їхніми одиницями і поведінкою на краю:

ОбмеженняОголошується якНа межі
вікно вступуentryWindow: TimeSpan — скільки вступ лишається відкритим після відкриття циклувступ після його закриття відхиляють; цикл усе одно триває до свого скидання
максимум учасниківmaxEntrants: int — записів в одному цикліучасника 65 із 64 відхиляють конфліктом, і нічого не витісняють: таблиця, що відкидала б найгірші рядки, ранжувала б того, хто прийшов першим
спроби на циклattemptsPerCycle: int — відправок на власниканаступна відправка відповідає «спроби вичерпано» — конфлікт, а не помилка дозволу, і лічильник скидається разом із циклом
вимога вступуjoinRequired: true — учасники — це членство, а не всі, хто граєвідправку від неучасника відхиляють

Щоденний турнір оголошує всі чотири від початку до кінця.

Помилки

Сесія player, що викликає операцію, яку ця таблиця лишає за fn, — відправку в таблицю лише для сервера, раннє закриття циклу — відхиляється як помилка дозволу до того, як щось записано; той самий виклик із хмарної функції проходить. Відправка в цикл, який уже закрився, — натомість конфлікт: право є, циклу немає, а повтором є відправка в поточний.

Обмеження

Кожен ліміт із тим, що стається на його краю.

МежаНа краюЧисло
рядків на читаннясторінку підрізають, «є ще» лишається істинним, after: продовжуєстеля сторінки задається на проєкт
вікно навколо власникапідрізають симетричностеля вікна задається на проєкт
записів в одному циклівідправку відхиляють як конфлікт; витіснення немаєmaxEntrants на таблицю; без ліміту, коли не задано
спроб на власника на циклконфлікт «спроби вичерпано», знімається скиданнямattemptsPerCycle на таблицю; без ліміту, коли не задано
частота відправок на власникавідмова рейт-ліміту, що несе мить, коли повтор дозволеночастота задається на проєкт
таблиць на проєктнове Declaration відхиляють на деплоїліміт задається на проєкт
зберігання закритих циклівцикл лишає сховище з Event'ом; далі читання відповідають not-foundвікно зберігання задається на проєкт

Шлях користувача

Один тиждень таблиці weekly-score: серверні відправки, читання навколо-мене, понеділкове закриття і його нагороди.

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Files & UGC

Файли приходять шматками і обробляються в міру надходження. Вивантаження, ассети і їхні похідні варіанти, а також створений гравцями контент зі шляхом модерації.

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

Коли застосовувати

  • Гравці чи сервіси вивантажують блоби — шматками, з відновлюваними сесіями і доступними для читання квотами на гравця.
  • Обробка має початися до кінця вивантаження — читайте файл потоком, шматок за шматком.
  • Створеному гравцями контенту потрібен шлях модерації — SubmitUgc, черга, вердикт, Hooks на обох кінцях.
  • Одне майстер-зображення має обслуговувати багато платформ — виводьте варіанти (масштабування, транскодування), а оригінал лишайте канонічним.
  • Не потрібно для маленьких структурованих payload'ів — поле запису Data несе їх без сесії вивантаження.

Хто що робить

ActorНа цій сторінці
playerвивантажує шматки, читає файли потоком, подає UGC
moderatorпереглядає чергу, схвалює або відхиляє подання
backend-serviceвиводить варіанти ассетів; чіпляє Hooks на вивантаження й модерацію; задає квоти

Одним поглядом

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC, шлях гравця:

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

Ворота навколо нього — це Hooks, той самий контракт, що й усюди:

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

Обидва Hooks огортають крок операції, ніколи Event: Before(Files.Upload) вирішує, чи почнеться вивантаження, After(Files.SubmitUgc) виконується, щойно подання існує, і кладе його в чергу модерації через власний Moderation.Enqueue модуля. Events — вивантаження завершено, варіант готовий, UGC подано, вердикт модерації — ідуть підписникам, а підписник ні на що не накладає вето.

Модель

Файл — це непрозорі байти плюс оголошені метадані — походження, тип вмісту, розмір, — і він не є сховищем стану, на якому ухвалюють рішення. Його зв'язок з ігровою моделлю йде в інший бік: поле на вашому типі тримає посилання; файл про гру не знає.

Що оголошує рід файлу.

ОголошуєЩо це
originавторський контент, згенерований грою або створений користувачем — і ліміти розміру та політики випливають звідти
admissible content typesоголошеним списком, ніколи не винюхані з байтів
size limitsперевіряються коли сесію відкривають, за оголошеним розміром, а не на останній частині
part size and orderвивантаження виконує сесія: оголошений розмір частини, порядок частин, точка відновлення
derivativesза бажанням, іменовані варіанти, які виробляє обробник, — і готовність кожного оголошеного варіанта спостережувана, тож клієнт ніколи не вгадує, чи вже існує мініатюра
storage prefixна полі схеми, що несе посилання: де живуть байти, і більше нічого. Не каталог — ані перейменування, ані переміщення, ані дозволів на префікс, ані рекурсивних операцій. До файлу все одно звертаються за його id чи key, і префікс у цьому участі не бере

Що правдиве для кожного файлу.

ЗавждиЩо це
completionідемпотентне за сесією: повторне завершення повертає той самий файл, а не другий
a checksumобов'язкова, і розбіжність — це відмова, ніколи не мовчазне прийняття зіпсованих байтів
a published fileнезмінний: правка — це нова версія, а посилання на версію далі вказує на те, на що вказувало
authored contentадресується ключем плюс версією, і воно managed: його не правлять в адмінській консолі, бо ним володіє код
an unfinished sessionвмирає спостережувано: після строку її припиняють з Event'ом, а її частини звільняють
ownershipіде за предикатом власника: файли, створені користувачем і згенеровані грою, мають власника, як будь-який власний рядок, а файли власника коряться політиці видалення гравця — каскад, відмова чи анонімізація, оголошені, а не припущені
read accessможе залежати від права: платний ассет закритий правом із commerce, а не другою системою дозволів

Кожна точка розширення називає тип, який вона передає Hook'ові, — намір вивантаження до вивантаження, подання після, — тож Hook ніколи не отримує нетипізованого мішка.

Помилки

  • Відсутність права відповідає not found, а не forbidden — інакше список відмов розкриває, які доповнення існують. Знятий файл відповідає так само.
  • Сесія, що спливла, — це конфлікт: відкрийте нову.
  • Частина поза оголошеним порядком чи розміром, неоголошений тип вмісту і розмір понад ліміт — це валідаційні відмови, і та, що про розмір, виявляється при відкритті сесії, а не після того, як байти передано.
  • Розбіжність контрольної суми — це валідаційна відмова, яку варто повторити: надішліть частину ще раз.
  • Вичерпана квота — це конфлікт, який можна повторити після звільнення місця.
  • Дозвіл на читання, що сплив, відповідає not authenticated — просіть новий дозвіл, а не вважайте це проблемою прав.
  • Відхилення на перегляді — це вердикт, а не відмова: подання розглянули, і відповідь негативна з оголошеною причиною, тож що робити далі, залежить від причини.
  • Перевищена частота вивантажень відповідає в категорії рейт-ліміту зі строком.

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Розмір файлу за походженням — вивантаження відхиляють до того, як прийняли бодай одну частину, а не на останній.
  • Розмір частини — частину відхиляють як валідаційний збій.
  • Час життя сесії — expired з Event'ом, і частини звільняють.
  • Квота сховища на проєкт і на гравця — нову сесію відхиляють як конфлікт, і вже опубліковане ніколи не видаляють мовчки, щоб звільнити місце.
  • Скільки версій авторського контенту тримають — найстарішу знімають, а версію, на яку посилається чинний Environment, — ніколи.
  • Частота вивантажень на Actor'а — рейт-ліміт зі строком.
  • Зберігання згенерованого грою контенту — коли період минув, зняття з Event'ом.

Шлях користувача

Один рівень, побудований гравцем, від першого вивантаженого шматка до вердикту про схвалення.

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Analytics

Усе, що треба порахувати потім, а не побачити зараз. Оголосіть типізований Event телеметрії, випустіть його — і він стане поруч із власними подіями платформи: пройдений рівень, крок воронки, економічна подія, тривалість сесії, відтік у навчанні. Цей модуль випускає; він не читає, не агрегує і сам нікуди нічого не відправляє — напрямок, у якому йде пакет, належить маршрутизатору, в Extensibility.

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

Коли застосовувати

  • Щось треба порахувати потім — крок воронки, пройдений рівень, економічну подію, тривалість сесії.
  • Порівняння має пережити збірки гри — тип несе версію схеми, тож річна воронка не є мовчки склейкою двох різних значень одного поля.
  • Обсяг великий, і втрачений рядок прийнятний, якщо ви так сказали, — телеметрія є єдиним місцем у контракті, де оголошена втрата законна.
  • Не потрібно, коли хтось має зреагувати: у Event'а телеметрії немає підписників узагалі; факт, який інші мусять почути, — це ігровий Event.

Хто що робить

ActorМожеНе може
any actorоголошувати типи у схемі; випускати від свого імені, по одному або пакетом; читати оголошені типизаповнювати контекст; читати, запитувати чи агрегувати випущене
backend-serviceте саме, плюс випускати від імені гравця за делегуваннямчитати телеметрію — дозволу на читання немає, бо немає операції читання

Одним поглядом

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

Визначення — це схема, тож те, що приходить, несе іменовані типізовані поля, а не JSON-блоб, — і оголошується воно у власній формі носіння, а не ігровим Event'ом із прапорцем, тож зрозуміти, що це таке, можна з Declaration, нічого не запускаючи.

Модель

Що оголошує тип Event'а телеметрії.

ОголошуєЩо це
nameвласне ім'я типу
fieldsтипізовані системою типів платформи; маска полів застосовується до них, як і всюди
schema versionобов'язкова і не виводиться з версії SDK — воронка порівнює події, зібрані під різними збірками гри, а без версії порівняння мовчки змішує непорівнянне
samplingяка частка подій цього типу проходить. Оголошується на типі, ніколи не обирається реалізацією за навантаженням: частка, що змінюється сама, робить воронки непорівнянними між днями, і помічають це лише після того, як на них ухвалили рішення
loss toleranceчи терпить цей тип втрату. Телеметрія — єдине місце в контракті, де оголошена втрата законна
deletion behaviourяк видалення гравця доходить до цього типу — видаленням чи анонімізацією. Політику оголошує студія; модуль її виконує

Що правдиве для кожного Event'а телеметрії.

ЗавждиЩо це
no addressing targetані отримувача, ані Group, ані підписки. Бажання вказати отримувача — це знак, що потрібен ігровий Event
contextналежить платформі: вона додає Actor'а або мітку анонімності, сесію, Environment, версію збірки і мить за оголошеним годинником. Викликач не може його заповнити: викликач, що підставляє Actor'а чи версію збірки, дістає виведені значення замість переданих
the sampling shareіде разом із подією: без неї абсолютне число не відновити з того, що надійшло
lossспостережувана в сукупності: частка недоставленого за період доступна споживачеві; поелементна спостережуваність не обіцяна, бо на обсягах телеметрії повідомлення на кожну втрату само стало б потоком
emissionне ідемпотентний: два виклики — це два факти, і він не приймає ключа ідемпотентності, бо придушення другого втратило б дані. Способу встановити наслідок утраченого випуску немає, і він не потрібен: терпимість до втрат оголошують заздалегідь, на всі виклики одразу
the eventsі є історією: власної модуль не тримає

Помилки

  • Неоголошений тип і поле, яке не збігається зі схемою типу, — це валідаційні відмови; жодна з них не є несподіванкою в рантаймі, бо тип доходить до адмінської консолі зі свого Declaration.
  • Завелика подія — це валідаційна відмова; поля ніколи не затискають мовчки.
  • Перевищена частота відповідає в категорії рейт-ліміту, несучи час, до якого повтор безглуздий.
  • Викидання за семплінгом не є помилкою, як не є нею й припустима втрата. Виклик було виконано, а викидання — оголошена поведінка; звітувати про будь-яке з них як про збій зробило б оголошену поведінку невідрізнимою від несправності.
  • Пакет буває цілим або поелементним, і те, який саме, оголошено, — ніколи не «як вийде».

Обмеження

Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.

  • Частота подій на Actor'а — відмова рейт-ліміту з часом. Перевищення частоти ніколи не викидає мовчки: або та відмова, або оголошене викидання за семплінгом, і третього наслідку немає.
  • Розмір однієї події — валідаційна відмова, ніколи не мовчки затиснуті поля.
  • Розмір пакета — відхиляється до відправлення, а не застосовується частково.
  • Оголошені типи на проєкт — нове Declaration відхиляють на деплої, а не в рантаймі.
  • Поля в типі — так само, на деплої.
  • Період зберігання — щойно він спливає, подія недоступна за оголошеним періодом.

Шлях користувача

Смертельний удар стає типізованим Event'ом телеметрії, семплюється своїм Declaration і рахується потім. Здібність і стата, які його несуть, — це пресети entity, а не модулі.

Beyond the SDK

Операторська площина

Те, чого в SDK навмисно немає. Життєвий цикл Project'ів і Environment'ів, деплой і відкат, білінг, адміністрування організації та користувачів, маршрутизація кластера — усе це належить адмінській панелі, CLI та поверхні MCP, а не ігровому коду. Єдиний навмисний виняток — Schema as Code: схема — це поверхня для розробника, тож вона в SDK.

Одна модель, дві площини

Площина SDKОператорська площина
Досяжна зігрового кодуControl Panel, CLI, MCP
ТримаєRooms · entities · гравців · комерцію · LeaderboardsProjects і Environments · деплой і відкат · білінг · адміністрування організації та користувачів · маршрутизацію кластера
Працює вDeclarations, Hooks, Events, Operationsвласних екранах панелі

Вони ділять одну модель: Declaration, яке ви відправляєте, — те саме, яке рендерить панель. Через лінію не переходить авторитет: ігровий код не може деплоїти, виставляти рахунки чи переносити тенанта.

Кожна поверхня SDK — кожен модуль і пресети, оголошені на entities, — має операторського двійника в Control Panel, де ті самі Declarations переглядають і правлять з іншого боку:

Поверхня SDKОператор бачить
Schema / Dataentities, міграції, перегляд записів, збережені подання, імпорт/експорт
Entityмашини станів, огляд кожного екземпляра
Entity Presetsтаблиці дропу, визначення здібностей, Stat і снарядів, пресети об'єктів світу — з живим підкручуванням
Accessсітку ролей: ролі × операції, фільтри рядків, маски колонок
Extensibilityланцюги сценаріїв із перевизначеннями, розв'язаний порядок, трасування викликів
Roomsфлот: Rooms, здоров'я Tick'а, розміщення, стан розвантаження
Matchmakingчерги, тікети в обробці, криві послаблення
Commerceкаталог, планування вітрин, чеки, повернення
Leaderboardsцикли, виправлення рекордів (з аудитом), частоти відправок
Authпровайдерів, сесії, бани, сценарій автентифікації
Filesассети, черги перегляду UGC, квоти
Analyticsдашборди, форвардери, відставання прийому
Mapкарти і набори перешкод, живі екземпляри
Visibility / Collision / Locomotion / Predictionпідкручування на Room'у: правила, пари відповідей, вікна, вартість пакета на Actor'а
Groups / Messagingперегляд груп, шаблони, фільтри модерації, розклади
Botsпрофілі, квоти добору, ендпоїнти мозку
Inventory / Profileволодіння і передачі, подання і набори власних entities

Правило дизайну. Можливість SDK без поверхні в панелі невидима для live ops; поверхня в панелі без можливості SDK — брехня. Модулі постачають обидві половини разом, і Declaration, написане в будь-якому з місць, — це та сама модель в обох.

Подорож одного Declaration: schema-author його пише, playserv push його доставляє, panel рендерить його для operator, і переналаштування набуває чинності в Rooms, які вже працюють.

Доступ для агентів

Усе, що показує панель, досяжне й для інструментів: платформа виставляє поверхню MCP (те саме API, яким користується панель), тож AI-агенти і скрипти оперують Project'ами (bootstrap, схема, записи, гравці, деплої) під тією самою моделлю доступу, що й будь-який інший Actor.

Beyond the SDK

Під капотом: транспорт і хаб

Архітектурний довідник, а не поверхня, яку ви викликаєте. Ніщо на цій сторінці не з'являється в API, під яке ви пишете: немає ані сокета, який треба відкрити, ані каналу, який треба обрати, ані конверта, який треба заповнити, ані повтору, який треба запланувати. Ваш ігровий код ніколи не зустрічає механіки цієї сторінки — у цьому й суть. Основні поняття називають стек; механізм живе лише тут. Він тут, щоб архітектор міг перевірити, що SDK робить з обірваним з'єднанням, вимкненим модулем чи повідомленням, яке має прийти рівно один раз.

Стек шарів

The runtime stack from your code down to the transports, with the line below which you never call anything

П'ять шарів, згори вниз: простір користувача, модулі, Primitives, хаб і адаптери транспорту під ним. Два верхні — простір користувача; усе нижче — власна справа SDK.

  • Простір користувача — це ваш код. Він бачить модулі, і словник на цьому закінчується.
  • Модулі — прикладний шар: Rooms, Matchmaking, Inventory, Leaderboards. Вони утворюють граф, а не дерево, і саме це хабу доводиться розв'язувати, коли один із них вимкнено (Inheritance & Composition — сама ця форма).
  • Primitives — перша реалізація, на яку посилається все: дані, Events, RPC, Groups. Модуль — це іменована збірка Primitives плюс власні правила.
  • Хаб — це контролер: впровадження залежностей, монтування модулів, сесія користувача, відновлення стану, якість обслуговування повідомлень і маршрутизація кожного вхідного повідомлення до модуля, змонтованого під нього.
  • Транспорти — це адаптери на протокол. Їх кілька; хаб поводиться з ними однаково.

Транспорти — це адаптери

Транспортів буде більше ніж один, і вони різняться так, що інакше це протікало б у кожен модуль:

ВісьДіапазон
Формакерований повідомленнями або керований запитами
Каналиодноканальний або багатоканальний
Станз відновленням стану з'єднання або без
ПротоколTCP або UDP

Сьогодні це означає WebSocket, UDP-транспорт PlayServ і звичайний HTTP. Кожен із них — адаптер за власними деталями реалізації, і кожен виставляє вгору те саме: транспортну сесію. Хаб тримає сесію, а не сокет, тож ніщо над адаптером не міркує про мережевий інтерфейс.

Оголошена межа. Один транспортний канал і одна транспортна сесія за раз. Тримати бекендний транспорт для Leaderboards, поки транспорт master-client несе живу сесію, — поза межами, і API цього не обіцяє: немає сигнатури, яка має сенс лише з кількома відкритими каналами. Чи відкриє це пізніша версія — вирішується разом із інваріантною поверхнею на проєкт; доти контрактом є форма з однією сесією.

Хаб ховає транспорт повністю

Униз хаб говорить транспортним інтерфейсом. Угору він пропонує стан, Events і сесію користувача. Код модуля і код гри однаково нездатні сказати, який транспорт унизу, як хаб зібрав виклик у пакет або що він робив, щоб повернутися до узгодженого стану після проміжку.

  • Сесія користувача належить хабу, а не модулю. Перепідключення, продовження і відновлення стану стаються один раз, на хабі, для всього, що на ньому змонтоване.
  • QoS повідомлень належить хабу, а не модулю даних. Конверти, повтори й пакування — механіка хаба.

QoS повідомлень — рівно три рівні

Модуль оголошує лише ту гарантію доставки, якої потребує:

РівеньЗначення
at least onceпередоставляється, доки не підтвердять; отримувач терпить дублікати
at most onceнадсилається один раз, ніколи не повторюється; втрата прийнятна
exactly onceдедуплікується і підтверджується; дорогий, використовується там, де він обов'язковий

Ця Declaration і є всією розмовою про доставку. Як саме гарантію дотримано — не справа модуля, і не ваша.

DI, монтування і вимкнені модулі

Хаб створює модулі й монтує їх — у корінь або в простір імен, — розв'язуючи залежності кожного модуля від Primitives та від інших модулів. Збірка, якій модуль не потрібен, його не монтує. Монтування має простори імен, і другий модуль, що претендує на вже зайняту точку монтування, відхиляється під час монтування: композиція падає там, а не на першому виклику до неї.

Оскільки модулі утворюють граф, вимкнення одного має наслідки далі по ланцюжку, і хаб іде рівно одним із двох шляхів:

  1. Вимкнути залежний ланцюг. Кожен модуль, якому потрібен відсутній, теж вимикається, і його інтерфейси відсутні, а не падають.
  2. Оголосити деградовану функціональність. Залежні лишаються змонтованими і повідомляють, чого вони більше не можуть робити.

Третього шляху немає. Мовчазне напівпрацювання — змонтований модуль, що тихо викидає операції, яких більше не виконує, — це саме той режим відмови, якому це правило й покликане запобігти, і саме тому вимкнена залежність спостережувана, а не загадкова.

Чому ви нічого з цього не зустрінете

Кожну обіцянку зі сторінок модулів дотримано вище цієї лінії: мутація Entity і є мережевою операцією, Hook — це типізована функція, вхід — це один виклик. Назви зі стека можуть до вас дійти — Основні поняття вказують сюди, — але обіцянка в тому, що ви ніколи нічого з цього не викликаєте, а не в тому, що слова таємні. Шари нижче існують, щоб ці обіцянки пережили зміну транспорту, і сторінка, яку вам ніколи не доведеться читати, — це міра того, що воно працює.

Те, що ви зустрінете, — контекст доставки, на якому виконуються ваші обробники, момент, коли закінчується хендл, і in-memory реалізація, на якій ви тестуєте, — це на сторінку вище: Потоки, час життя і тестування.

Start here

PlayServ SDK

随玩法一起交付的游戏后端。 PlayServ 是面向在线游戏的后端即服务:一家工作室运行自己游戏的后端——数据、玩家、Rooms、matchmaking、商业化——却不必自己托管一套。而 SDK 就是你的代码(无论在服务器上还是在引擎里)与这个平台打交道的方式。

本页是一份短清单,列出这里到底有什么不一样。下面的每一项都是为一个 Project 决定一次、随后靠配置而非编写来落地的;你真正调用的东西写在各个模块页上,而这里的每一节结尾都会点名拥有它的那个模块。

One declaration you write, and the five things that happen with no further code from you

模拟不是你的代码

Four steps of a room tick run inside the platform; one arrow leaves it, and that one is your hook

Rooms、碰撞、移动、预测和同步全都在平台内部运行。你的游戏是 Declarations(Entities、地图、abilities、掉落表、同步策略)、Hooks(你的规则,在被命名的步骤上被调用)、Events(订阅,而不是轮询)和 Operations(你请求或命令的东西)。每一个模块页都正好围绕这四者来组织。

你省掉的东西是具体的——一个游戏循环、快照组装、一个 delta 编码器、碰撞解算、运动积分、重连处理,以及带延迟补偿的命中判定。参见 Getting Started,它构建的正是这些。

修改已声明的状态就是那次网络调用

没有 send。你声明一个字段如何同步——字段旁边的一个特性而已——而修改它就是那次网络 Operation:相对上一次已确认状态的 Deltas、作为策略单位的切面、优先级和发送速率、保留窗口、变更前与变更后的 Hooks。它下游的一切都不用你来写。

同样的手法适用于其他所有被声明的东西:一个 Event、一次 RPC、一个 Group、一条 leaderboard 轴。一份 Declaration 同时是类型化 API 的输入、是渲染它的管理面板的输入、也是每一种绑定的 codegen 的输入——这也正是为什么你要版本化的是 Declaration,而不是生成出来的代码。参见 Data & Subscriptions 和 Schema as Code。

接口跟着 Actor 走,而不是跟着某一端走

这里没有客户端 SDK,也没有服务端 SDK。只交付一个 SDK,而一次调用被允许做什么,取决于它背后的那个 Actor——一名玩家、一个服务、一个 bot 的大脑、一位运营人员。

它所针对的那个场景,是一台玩家机器创建了一个 Room 然后又运行它:一个 master-client,持有 room-owner 的那些接口,仅此而已。一个 room-visitor 的构建里没有踢人也没有关闭——不是被禁用,是压根不存在。

权利由原子权限组合而成,所以这里没有内建的角色层级,而一个角色可以把数据一直管到行和列。参见权威性,以及 Access & Roles 了解一次授权是怎么写出来的。

一套设计,收窄两次——跑在同一套栈上

它是怎么写出来的

这个 SDK 是一套设计加两道收窄的出口,而顺序本身就是规则:只有上一层实在承载不了,某样东西才会往下沉一层。共通的原则在每一种绑定里都完全一致。一门语言的形状只接收它的范式无法用共通方式表达的部分——C# 有特性,Python 有装饰器,同一份 Declaration 用各自语言本来就用来表达这个想法的写法写出来。一个引擎的形状只接收引擎在它的语言之上重塑的部分:在 Unreal 里,一份 Declaration 就写在引擎自己的反射宏里面,而 Unity 的 C# 也不是服务端的 C#。

它是怎么运行的

你的游戏代码只面向模块,别的都不碰。模块由四个 Primitives 装配而成——Events、RPC、Data & Subscriptions、Groups。它们下面是你从不调用的 hub:依赖注入、模块挂载、会话、状态恢复、消息服务质量。再下面是传输适配器,每种协议一个,而一次调用由哪一个承载,既不由你的代码决定,也不被你的代码察觉。

两半的完整内容:SDK 是怎么建起来的,一路读到深入内部。

模块靠组合;没有任何东西靠继承

没有可以派生的基础模块,也没有可以扩展的层级——模块构成一张图,因为树只允许分叉,而真实的功能会横跨这些分叉:matchmaking 要在 Rooms 里预留座位,掉落要通过 Map 放置物品,聊天住在一个 Room 里面。

“继承”在这里指的是四种不同的机制,而把它们分开来说是值得的:一个 Entity 的 RPC 属于这个 Entity,别处不存在。一个 preset 是一组按名字打包的切面,不是基类。覆盖平台的某一步,是写在你的替代实现上的一个特性。一个模块借用另一个模块,中间隔着一个装饰器,把借来的接口收窄。参见继承与组合。

谁看到什么是声明出来的,不是在客户端过滤出来的

四十个玩家时,整个 Room 的快照没问题;两百个时就不行了,而解决办法不是加粗管道。兴趣规则决定谁收到哪一片,而按 Actor 的分包和广播只是同一个已声明模型的两种投递模式——在两者之间切换是配置,不是重写。远处的东西会先经过已声明的细节层级降级,然后才消失。

其中属于安全属性而非带宽属性的那一部分:绝不能泄露的状态从来不会被发出去。 战争迷雾和只有所有者可见的字段,是不在数据包里,而不是在客户端被藏起来。观战者、管理员和录像回放能看到更宽的视野,是因为他们持有更宽的授权——又是权威性,而不是一个特例。参见 Visibility。

丢掉一台主机之后什么还在

一台宿主死掉并不会结束这场比赛。对局进行期间,Room 的状态不会在宿主之间复制——单一所有者正是把定序挡在共识之外的东西——而让这场比赛能挺过去的,是状态是被声明的,而被声明的状态保存在宿主之外。它按一个已声明的间隔被快照,而一个替补会从最后一份快照继续。

所以替补拿到的状态是完整的——但那是那份快照那一刻的。完整,而不是当前。它的代价是最后一份快照以来的那段对局;没有被覆盖的,是你只留在引擎 actor 里的那些东西。一次部署走的是同一套机制,只是没有损失:把一台宿主排空,就是这条故障切换路径被有意地跑一遍。参见 What Survives Losing a Host,以及 Rooms 里玩家重新进入所走的那个宽限窗口。

平台的任何一步都可以变成你的

平台的每个场景都是一串已注册的函数,而你可以替换其中一环,或者把它包起来。登录、入场校验、购买、成绩提交、上传——每一个都是一个被命名的步骤,而你的替代实现用一个特性声明出来,版本按条件挑选,平台自己的那一步作为兜底。

这就是“可定制的平台”具体意味着什么,也是我们用来代替把源码交给你的做法:你替换的是那些步骤,而不是 fork 掉运行这些步骤的东西。参见 Extensibility。

一套接口面,六种语言

一份契约,六种投影:服务端的 C#、TypeScript、Python 和 Go;引擎里生成的 Unreal C++ 和 Unity C#。本站的每一段代码示例都会展示全部六种,而当某种绑定对某一步没有对应接口面时,那个页签会说明原因,而不是假装有——可能是这一步跑在引擎之外,也可能是另一个 Actor 才持有做这次调用的权利。

在你挑语言之前值得知道的两个后果:RPC 按引用接收 SDK 对象,而不是拍平的 DTO;异步 Primitives 是核心的一部分,而不是外挂上去的——Channels、Streams 和按 Group 寻址,所以你可以对一整个 Group 说话并收集答案,也可以在一个文件还在上传时就按块消费它。

另外还有一个确定性的内存宿主环境,它在背后没有任何后端、时间由你控制的情况下运行你的游戏代码,于是一个测试才是测试,而不是一场竞态——参见线程、生命周期与测试。


那些被有意排除在 SDK 之外的东西——部署、计费、组织与用户管理——住在运营平面上。侧边栏是其余一切的地图;Getting Started 是最短的入口。

Start here

Getting Started

一个能玩的竞技场(地图、坦克、射击、掉落),从头到尾都是声明出来的。下面没有一处是游戏循环:模拟在平台内部运行,而这就是全部的代码。

开始之前。 你需要一个带 dev Environment 的 Project(在运营平面上创建,那个生命周期归它管)、一个已登录到该 Project 的 playserv CLI,以及你所用绑定的 SDK 包——除此之外,什么都不会装进你的游戏。

用户流程

A first match end to end: four calls to get in, then a loop you did not write, with your hooks running at the steps the platform names

你发起的每一次调用都是下面的示例之一;它们之间的那些步骤,是平台在按某份 Declaration 说的话行事。图里的 ability、stat 和 drop-table 都是 Entity Presets——它们是 Entities 上的 Declarations,而不是各自独立的模块。

1. 声明这个世界

Entities 是你的 schema 加上它们的实时切面。每种行为一个特性,就写在它所描述的字段旁边:

Declaring the Tank entity: three sync policies and three gameplay aspects, one line each
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;                     // synced every tick
  @Sync({ hz: 10 }) fuel = 0;                  // ~10 times a second
  @Sync({ to: Scope.Owner }) ammo = 0;         // owner's eyes only

  @Stat({ max: 100, atMin: 'death' }) hp: Stat;
  @Body({ shape: 'capsule', radius: 0.6 }) body: Body;
  @Motion({ model: 'tank', maxSpeed: 8, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()                      # synced every tick
    fuel: float = sync(hz=10)                    # ~10 times a second
    ammo: int = sync(to=Scope.OWNER)             # owner's eyes only

    hp = stat(max=100, at_min="death")
    body = collision.body(shape="capsule", radius=0.6)
    motion = locomotion.motion(model="tank", max_speed=8.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                        FVector3f Position;   // synced every tick
    UPROPERTY(PSSync = (Hz = 10))            float     Fuel;       // ~10 times a second
    UPROPERTY(PSSync = (To = "Owner"))       int32     Ammo;       // owner's eyes only

    UPROPERTY(PSStat = (Max = 100, AtMin = "death"))                                FPSStat Hp;
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6"))                         FPSBody Body;
    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", TurnRateDeg = 120))     FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, and nothing here needs C# 12 — it builds on the 2021.3 baseline
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;                     // synced every tick
    [Sync(Hz = 10)] public float Fuel;               // ~10 times a second
    [Sync(To = Scope.Owner)] public int Ammo;        // owner's eyes only

    [Stat(Max = 100, AtMin = "death")] public Stat Hp;
    [Body(Shape.Capsule, Radius = 0.6f)] public Body Body;
    [Motion(Model.Tank, MaxSpeed = 8f, TurnRateDeg = 120f)] public Motion Motion;
}

修改一个 [Sync] 字段就是那次网络 Operation。没有快照要组装,也没有 send 调用要发。

上面那些类型没有一个需要你来定义,而且每一个都归某一页所有:

代码块里的来自
Vector3、Stat你所用绑定的核心包
Body 以及各种 body 形状Collision
Motion 以及五种运动模型Locomotion
ObstacleSet、Drop、Flight、Ammo、Effect用到它们的那些 Entity Presets
EntryRequest、Verdict、StatEventHook 的载荷,由你挂钩的那个模块递进来
SeatMatchmaking
Scope 以及各种同步作用域Visibility
Tick 以及各档 tick 速率Rooms

这些枚举是封闭的。没有任何成员能覆盖的规则,写成谓词而不是新增一个成员:当 Scope.Owner 并不完全是你想表达的那条规则时,[Aspect("loadout", Visible = "owner == caller.player")] 就是按字段表达可见性的写法(Data & Subscriptions)。

2. 声明这个 Room

一个 Room 模板说明一场会话是什么,并点名它所依赖的那些 Declarations。没有 Room 类要继承,也没有 tick 方法要填,因为 Room 的内部实现是平台的:

The battle template and the three declarations it names: an arena, a loot table, a weapon
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}
@RoomTemplate('battle', { map: 'arena' })
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
}
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
}
@DropTable('crate-loot')
export class CrateLoot {
  @Entry('ammo.shell', { weight: 60, count: [2, 4] }) ammoShell: Drop;
  @Entry('railgun', { weight: 1 }) railgun: Drop;              // the jackpot
}
@Projectile('shell', { cooldown: 1.5 })
export class Shell {
  @Ballistics({ speed: 24, gravity: 9.8 }) arc: Flight;
  @Ammo('ammo.shell', { perShot: 1 }) load: Ammo;
  @Effect({ damage: 35 }) onHit: Effect;
}
@room_template("battle", map="arena")
class Battle:
    capacity = 8
    tick = Tick.HZ30

@Map("arena", seed=42, bounds="160x160")
class Arena:
    rocks = scatter("rock", count=40, min_spacing=6)

@drop_table("crate-loot")
class CrateLoot:
    ammo_shell = entry("ammo.shell", weight=60, count=(2, 4))
    railgun = entry("railgun", weight=1)                       # the jackpot

@projectile("shell", cooldown=1.5)
class Shell:
    arc = ballistics(speed=24, gravity=9.8)
    load = ammo("ammo.shell", per_shot=1)
    on_hit = effect(damage=35)
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Map = "arena", Capacity = 8, Tick = 30))
struct FBattle { GENERATED_BODY() };

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6)) FPSObstacles Rocks;
};

USTRUCT(PSDropTable = "crate-loot")
struct FCrateLoot
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Item = "ammo.shell", Weight = 60, Count = "2..4")) FPSDrop AmmoShell;
    UPROPERTY(PSEntry = (Item = "railgun", Weight = 1))                     FPSDrop Railgun;  // the jackpot
};

USTRUCT(PSProjectile = (Name = "shell", Cooldown = "1.5"))
struct FShell
{
    GENERATED_BODY()

    UPROPERTY(PSBallistics = (Speed = "24.0", Gravity = "9.8")) FPSFlight Arc;
    UPROPERTY(PSAmmo = (Item = "ammo.shell", PerShot = 1))      FPSAmmo Load;
    UPROPERTY(PSEffect = (Damage = 35))                         FPSEffect OnHit;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same four declarations compile in Unity; a Unity build reads the pushed template and joins rooms from it
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

那些带引号的 id 是内容键,不是自由文本:

键它指的是什么
rock地图障碍物集合里的一个道具(Map)
ammo.shell、railguncatalog 条目(Catalog & Commerce)——射击扣弹药、拾取落进包里,走的也是这条路
battle、arena、crate-loot、shell这四份 Declarations 各自注册的键

playserv push 会拒绝一份其键在目标 Environment 中并不存在的 Declaration,所以一个打错的键会在部署时失败,而不是在第一次开火时才失败。不管这份模板写在哪里,都不必重新部署引擎就能重新调参:推上去的那个模型,就是 live-ops 在面板里编辑的东西。

3. 把你的规则写成 Hooks

Hooks 是平台在被命名的步骤上调用的云函数。输入有类型,输出有类型——没有什么塞满杂物的 context 对象,签名里也没有 logger:

Three rules as hooks: reject banned players at the door, hand a new player 20 shells, roll loot on death
[Before(Rooms.Entry, room: "battle")]
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? entry.Reject(Problem.Banned, "banned from this project")
        : entry.Accept();

[After(Auth.SignIn, created: true)]
public static async Task GrantStarterPack(Player player)
{
    await player.Inventory.Grant("ammo.shell", count: 20);
}

[After(Stats.Depleted, stat: "hp")]
public static void OnDeath(StatEvent e) => CrateLoot.RollAt(e.Entity.Position);
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? entry.reject(Problem.banned, 'banned from this project')
      : entry.accept());

export const grantStarterPack = after(Auth.signIn, { created: true },
  async (player: Player) => {
    await player.inventory.grant('ammo.shell', { count: 20 });
  });

export const onDeath = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) => {
  CrateLoot.rollAt(e.entity.position);
});
@before(rooms.entry, room="battle")
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()

@after(auth.sign_in, created=True)
async def grant_starter_pack(player: Player):
    await player.inventory.grant("ammo.shell", count=20)

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

一道 Before 门禁可以拒绝这一步;一个 After 观察者在这一步提交之后才运行,因而拒绝不了。所以一个没能发下去的新手礼包,代价是 20 发炮弹,而不是那次登录。Extensibility 讲了其余部分。

那段代码里几乎没有一行真的在做它看上去在做的事:

那一行实际执行它的是什么
Stats.Depleted 触发是那个 Stat 触到了它的下限——对 Hp 来说是 0,因为 Declaration 只设了 Max
死亡状态转移第 1 节里的 AtMin = "death";Hook 加上的是后果,不是那次转移
伤害炮弹上的 [Effect(Damage = 35)],由平台在命中时施加
RollAt生成在 [DropTable] 这份 Declaration 上——这也是它为什么是 partial,以及 Go 页签里为什么写成 drops.RollAtCrateLoot
拾取开过战利品会把物品原子地转入玩家的 Inventory;那次转移就是 HUD 渲染的那个 changed Event

给引擎生成的类型

playserv schema codegen   # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/Generated

每次推送 schema 之后就运行它(或者让 CI 去运行)——类型是重新生成的,从不手工编辑,而生成出来的 Tank 就是被推上去的那个 Tank。playserv push 读一个引擎项目的方式,跟它读一个服务端项目完全一样:UHT 说明符和 C# 特性就是那份 Declaration,所以把 CLI 指向 UE 或 Unity 项目,导出这一步就全做完了。回调落在哪个线程上、一次订阅什么时候结束,都由运行时模型定死——线程、生命周期与测试。

4. 接入一个客户端

客户端 API 是对称的:同样那些模块,而一个构建被允许调用什么,取决于它运行时所用的那把密钥。一个引擎构建携带一个玩家密钥——这里的 projectKey,是对应一个 Project、一个 Environment 的凭据,在面板里签发并随构建一起发出去。它不点名任何角色:角色是每次请求时在服务端解析的,而它们背后的那个玩家随 SignIn 到达。引擎绑定在这里是一等公民;服务端绑定则以无界面的方式驱动同一套接口面(一个 bot 的大脑、一次压测、一个运维工具):

The client session: sign in, find a match, join, react to changes, drive and shoot
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);
const playserv = await PlayServ.connect(projectKey);
const session = await playserv.auth.signIn(Provider.Device, { create: true });

const seat = await playserv.matchmaking.find('battle');
const room = await playserv.rooms.join(seat);

room.entities<Tank>().onChange((tank) => render(tank));

const aim: Vector3 = { x: 24, y: 0, z: 12 };      // the world point under the crosshair
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });
await room.my<Tank>().cast(Shell, aim);
playserv = await PlayServ.connect(project_key)
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)

seat = await playserv.matchmaking.find("battle")
room = await playserv.rooms.join(seat)

room.entities(Tank).on_change(lambda tank: render(tank))

aim = Vector3(24, 0, 12)                          # the world point under the crosshair
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)
await room.my(Tank).cast(Shell, aim)
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        FPlayServClient* Client = ConnectResult.Value();
        Client->Auth->SignInAnonymous(FPSIdempotencyKey(DeviceId),
            TPSOnResult<FPSSession>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSSession>& SignedIn)
            {
                if (!SignedIn.HasValue()) { return; }
                FindBattle(Client);
            }));
    }));

// in FindBattle(FPlayServClient* Client): a ticket, the seat it wins, the room it opens
Client->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this, Client](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this, Client](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterBattle(JoinResult.Value());
                }));
        });
    }));

// in EnterBattle(FPSRoom* Room): render what you see, drive what is yours
TPSSubscription TankView = Room->Entities->Of<UTank>()->Select()
    .Subscribe([this](const TArray<UTank*>& Tanks) { Render(Tanks); });

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

var seat = await playserv.Matchmaking.Find("battle");
var room = await playserv.Rooms.Join(seat);

room.Entities<Tank>().OnChange(tank => Render(tank));

var aim = new Vector3(24f, 0f, 12f);              // the world point under the crosshair
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);
await room.My<Tank>().Cast(Abilities.Shell, aim);

四个调用,每一个作答的东西都不一样:

调用它以什么作答
Find投出的一张 ticket,以及某个 Room 里预留好的一个座位。这个预留会被保留模板所声明的那段时限;让它失效损失的是座位,而不是参战的权利
Join在这个 Room 的当前状态到达之后完成。模板为一名进场成员生成的那辆 Tank 就是那份状态的一部分,所以 My<Tank>() 在下一行就有答案,而 Join 之后的一切都是实时流量
Cast开火是能力的那个动词,而不是第二套接口面:一份 [Projectile] Declaration 就是一个上面加了弹道的能力,所以 Cast 会像对一次冲刺或一次治疗那样去检查冷却、弹药和目标(Entity Presets)
Abilities是生成出来的——codegen 把已声明的能力和抛射物收拢成每个 binding 一个类型

拒绝以平台的类型化 Problem 到达——一个代码,加上一条给人看的原因。C#、TypeScript、Python 和 Unreal 会把它抛出来;Go 把它作为错误值返回,这也是那个页签里每次调用都要检查的原因。一台 Unreal 专用服务器或者一个 master-client 运行的是同一个二进制,只是换成一个主机密钥,而它的角色带着 mc 那些行:Rooms → 托管一个 Room 就是这套接口面的完整形态,Access & Roles 则是它背后的密钥和角色被声明的地方。

5. 推送并开玩

playserv push          # schema + declarations + hooks, one deploy
playserv open battle   # a dev-env room, live in the panel

playserv push 会扫描它所运行的那个项目——特性形式的 Hooks 和 Declarations——然后部署到你指定的 Environment(默认是 --env dev)。playserv schema push 单独只搬模型,而 playserv schema diff 就是一次推送用来做差异比对的基准(Schema as Code)。

一次推送要么整体落地、要么完全不落地;如果已部署的 schema 在你做完 diff 之后又动过,这次推送会被拒绝,而不是被合并。会破坏既有数据的变更根本不搭这趟推送的车:它会变成一次迁移,你先读它,然后运行或取消(Schema as Code)。把一个模型从 dev 搬到 prod 是运营平面上的动作,而不是一次 SDK 调用(运营平面)。

playserv open battle 会用推上去的 battle 模板创建一个 Room 并在面板里打开它;你可以一边跟它对战,一边检视这个 Room 的状态和它的成员。面板现在会显示模板、地图、掉落表和 Hooks:跟你在代码里写的是同一个模型,而且在那里也能编辑。

那些不是你选的数字

Capacity = 8、Hz30、Hz = 10 和 Cooldown = 1.5f 是这款游戏的调参,不是上限。平台自身的限制在它们之上,而每一条都连同调用方在边界处观察到的现象一起声明出来:

在边界处调用方得到什么
超出容量、或进入一个已关闭 Room 的加入conflict——等有座位空出来时值得重试
超出每 Project 或每 Actor 上限的 Room 创建被拒绝,且已经创建出来的一个都不会被销毁
创建 Rooms 或登录过于频繁一次带上等待时长的限流拒绝
超过该 Room 上限的 Event 载荷在发送之前就被拒绝,绝不截断
超出某个角色行数上限的读取上限那么多行,外加一个说明“被截断了”的标记

这些数字本身是按 Environment 定的,会随平台的限制一起落地;而边界处的行为并不等它们(Rooms、Access & Roles、Auth & Players)。

接下来去哪儿

  1. 从示例学起,也就是紧接着这里的那一节:Tanks 里的 Leaderboard、Tanks 里的血包,或者讲大循环(meta)的每日锦标赛——每篇一个真实功能,每一步都链到拥有你刚用过那样东西的模块页。
  2. SDK 如何工作,等到它的整体形状开始比下一个功能更要紧的时候:核心概念是那本词典,另有四篇文章分别回答是谁在调用(权威性)、一次授权怎么写(Access & Roles)、SDK 由什么构成(SDK 是怎么建起来的)和它是怎么被执行的(线程、生命周期与测试)。
  3. 然后是各个模块。每个模块页的骨架都一样——论点、Actors、何时使用、用户流程、示例、模型——所以第二篇会比第一篇读得快,到第五篇只要几分钟。Entity 和 Data & Subscriptions 是其余一切所倚靠的那两个。

按角色划分的阅读路径

不管你是什么角色,先读权威性——一个 SDK、每个 Actor 一份授权,这是共同的前提——并把核心概念开在旁边。

你是按顺序读
游戏客户端开发(Unity、Unreal 客户端、TS)Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions,然后按功能来:Inventory、Leaderboards、Messaging、Profile
服务端开发(C#、TS、Python、Go)Schema as Code → 那些构建块 → Entity → Extensibility → Access & Roles,然后是那些 Declarations 归你所有的模块:Rooms、Matchmaking、Leaderboards、Catalog & Commerce
Unreal 专用服务器开发Rooms(托管一个 Room) → Bots → Locomotion、World Objects → Map → 丢掉一台主机之后什么还在
Examples

Tanks 里的 Leaderboard

Tanks,也就是 Getting Started 里那个示例竞技场,还没有 Leaderboard。这一课你在读完 Getting Started 之后随时都可以上,它用三步加上一块每周击杀榜:声明这块榜、从击杀 Hook 里提交、在客户端读它。每一步都链到拥有你刚用过那样东西的模块页,所以这一课靠指路来教,而不是靠重复。

第 1 步——声明这块榜

一块榜就是一份 Declaration:按哪个字段排名、重复提交如何合并、什么时候重置、以及谁可以提交。Aggregation.Increment 会把每次提交累加到累计总数上,所以一次击杀就是一分。Submit.ServerOnly 是默认值,它把这块榜对客户端关上,这也正是第 2 步成为唯一入口的原因。

Step 1: tanks-weekly-kills — kills descending, incrementing, resets Monday, server submits only
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}
@Leaderboard('tanks-weekly-kills')
export class WeeklyKills {
  static owner = Owner.Player;
  static agg = Aggregation.Increment;
  static reset = Reset.weekly(DayOfWeek.Monday);
  static submit = Submit.ServerOnly;

  @rank(1, Sort.Descending) static kills: number;
}
@leaderboard("tanks-weekly-kills")
class WeeklyKills:
    owner = Owner.PLAYER
    agg = Aggregation.INCREMENT
    reset = Reset.weekly(DayOfWeek.MONDAY)
    submit = Submit.SERVER_ONLY

    kills: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "tanks-weekly-kills", Owner = "Player", Aggregation = "Increment",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyKills
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Kills;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the board in step 3 and cannot submit to it
[Leaderboard("tanks-weekly-kills")]
public static class WeeklyKills
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Increment;
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);
    public static Submit Submit = Submit.ServerOnly;

    [Rank(1, Sort.Descending)] public static int Kills;
}

用 playserv push 推上去,这块榜就会出现在面板里,空的,而它的周一周期已经排好了——周一 00:00 UTC,因为排程都按 UTC。你没有设置的那些轴保持默认值。完整的轴列表——owner、排序键、展示字段、锦标赛规则——参见 Leaderboards。

第 2 步——从击杀 Hook 里提交

Tanks 已经通过坦克上声明的 HP 阈值来结束一条命:HP 归零时 death 转移触发,平台随后调用那个 Hook。这个 Hook 是一个云函数,输入有类型、输出有类型,所以提交一次击杀在它里面就是一行。

Step 2: an [After] hook on hp depletion submits one kill for the killer
[After(Stats.Depleted, stat: "hp")]
public static Task SubmitKill(StatEvent e) =>
    PlayServ.Leaderboards.Submit("tanks-weekly-kills", e.By.PlayerId,
        kills: 1, idempotencyKey: e.Id);
export const submitKill = after(Stats.depleted, { stat: 'hp' }, (e: StatEvent) =>
  PlayServ.leaderboards.submit('tanks-weekly-kills', e.by.playerId,
    { kills: 1, idempotencyKey: e.id }));
@after(stats.depleted, stat="hp")
async def submit_kill(e: StatEvent):
    await playserv.leaderboards.submit("tanks-weekly-kills", e.by.player_id,
        kills=1, idempotency_key=e.id)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

e.By 是伤害本身带着的那个攻击者,所以不需要任何账本去记谁打了谁。e.Id 是这个 Event 自己的 id,而把它当作幂等键传进去,正是一块 Increment 榜所需要的:一个被重复投递的击杀 Event 只计一次,不计两次。这个 Hook 点、顺序保证和否决契约都在 Extensibility;触发它的那个阈值是坦克上的一个 Entity Presets,而它写下的那一行分数是你可以查询的普通 Data & Subscriptions。

第 3 步——在客户端读这块榜

两次读取就覆盖了整个 UI:榜首那一段,以及本地玩家周围的那个窗口——上五行、下五行,加上你自己。两者都以带击杀数和显示名的排名条目返回,可以直接绑到一个列表上。一个订阅会在对局进行期间让面板保持最新,而它只投递本地玩家的名次。

Step 3: top 20 and five rows around me, plus a live rank subscription
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));
const top    = await playserv.leaderboards.top('tanks-weekly-kills', 20);
const around = await playserv.leaderboards.aroundMe('tanks-weekly-kills', 5);

playserv.leaderboards.onRankChanged('tanks-weekly-kills', (r) => updateHud(r));
top    = await playserv.leaderboards.top("tanks-weekly-kills", 20)
around = await playserv.leaderboards.around_me("tanks-weekly-kills", 5)

playserv.leaderboards.on_rank_changed("tanks-weekly-kills", lambda r: update_hud(r))
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyKills>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board):
Board->Entries->Select().Page(20).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine); });
// 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
var top    = await playserv.Leaderboards.Top("tanks-weekly-kills", 20);
var around = await playserv.Leaderboards.AroundMe("tanks-weekly-kills", 5);

playserv.Leaderboards.OnRankChanged("tanks-weekly-kills", r => UpdateHud(r));

周一的重置是把这个周期关闭,而不是把它删掉,所以上周那张表仍然可以按它的标签读出来——同一个 Top 调用,加一个 cycle: 参数。在周期关闭时挂一个奖励 Hook,是很自然的第四步,在 Leaderboards 上有描述。

接下来去哪儿

  • Leaderboards——那些轴、周期、锦标赛,以及给可疑分数封顶的提交前 Hook。
  • Extensibility——每一个 Hook 点,按顺序,附带否决契约。
  • Entity Presets——第 2 步里触发击杀的那个 Stat 阈值。
  • Tanks 里的血包——另一个 Tanks 示例:两份 Declarations 加一个 Hook。
  • Getting Started——这一课所扩展的那个 Tanks 竞技场。
  • 核心概念——每个模块页都默认你已经掌握的那套词汇。
Examples

Tanks 里的血包

之前: 一辆受了伤的坦克会一直带着伤,直到它死掉。没有回头路,所以每场战斗都是倒计时,而竞技场也没有理由让人穿行其中。

之后: 血包出现在竞技场各处,彼此拉开距离,也远离正在交火的人。开过去就会给你回血。游戏的其他一切都不变——这个 Room 的代码也不变,因为它根本就没有代码。

这是第二课 Tanks。它需要三步,不需要新模块:一份给血包的 Declaration、一份关于血包在哪里出现的 Declaration,以及一个决定捡起它会发生什么的 Hook。在 Getting Started 之后上这一课,和 Tanks 里的 Leaderboard 的先后顺序随意。

第 1 步——声明这个血包

一个血包是一个 Entity,应用了两个 preset,并带一个只报告接触、不拦住任何人的 body。pickups 层上的 Response.Pass 正是让它成为拾取物而不是障碍物的原因:接触被报告出来,而运动径直穿过去。

Step 1: a runtime-only crate on the pickups layer — contact reported, motion unaffected
[Entity("health-crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects })]
public class HealthCrate
{
    [Sync] public Vector3 Position;

    [Body(Shape.Sphere, Radius = 0.5f, Layer = "pickups")]
    [CollidesWith("vehicles", Response.Pass)]      // reported, motion passes through
    public Body Body;
}
@Entity('health-crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects] })
export class HealthCrate {
  @Sync position: Vector3;

  @Body({ shape: 'sphere', radius: 0.5, layer: 'pickups' })
  @CollidesWith('vehicles', Response.Pass)         // reported, motion passes through
  body: Body;
}
@entity("health-crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS])
class HealthCrate:
    position: Vector3 = sync()

    body: Body = body(shape="sphere", radius=0.5, layer="pickups",
                      collides_with=[("vehicles", Response.PASS)])
Coming soon — Go

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

UCLASS(PSEntity = (Name = "health-crate", Persistence = "Runtime", Presets = "world-objects"))
class UHealthCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSBody = (Shape = "Sphere", Radius = "0.5", Layer = "pickups"),
              PSCollidesWith = "vehicles:Pass")                             // reported, motion passes through
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity binding consumes the generated typed API (HealthCrate) on the client surface.

有两件事你不用写:血包画在哪里(客户端已经会渲染已声明的世界物体),以及它的位置怎么传到各个客户端——[Sync] 就是那次网络调用。

归 Entity Presets 和 Collision 所有。

第 2 步——声明血包在哪里出现

放置也是一份 Declaration,而这一步决定了这个功能玩起来公不公平。间距让血包不至于扎堆,与玩家的距离让它们不会刷进一场单挑里,而不重复规则让同一个点位不至于每次都是标准答案。

Step 2: crates on the ground layer — spaced, away from fighting, and never the same spot twice in a row
[DropTable("health-crates", Layer = "ground", MinSpacing = 8, AwayFromPlayers = 10, NoRepeat = 3)]
public static partial class HealthCrates
{
    public static readonly Drop Crate = Drop.Of<HealthCrate>(weight: 1);
}
@DropTable('health-crates', { layer: 'ground', minSpacing: 8, awayFromPlayers: 10, noRepeat: 3 })
export class HealthCrates {
  static crate = Drop.of(HealthCrate, { weight: 1 });
}
@drop_table("health-crates", layer="ground", min_spacing=8, away_from_players=10, no_repeat=3)
class HealthCrates:
    crate = drop_of(HealthCrate, weight=1)
Coming soon — Go

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

USTRUCT(PSDropTable = (Name = "health-crates", Layer = "ground", MinSpacing = 8,
                       AwayFromPlayers = 10, NoRepeat = 3))
struct FHealthCrates
{
    GENERATED_BODY()

    UPROPERTY(PSEntry = (Entity = "health-crate", Weight = 1)) FPSDrop Crate;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Runs off the engine

Declarations are authored in the server project and pushed with playserv push; the Unity client sees the results as spawned world items and pickup events.

有效位置来自 Map——这张表向 ground 层要一个点位,而地图回答的是一个真正到得了的点位,所以血包绝不会落进一堵墙里。

归 Entity Presets 和 Map 所有。

第 3 步——捡起时回血

一个 Hook,而它是这一课里唯一的代码。它作为云函数在平台上运行,这也是它在两个引擎页签上都不出现的原因。

Step 3: the pickup hook heals the tank, and refuses politely when there is nothing to heal
[Before(Drops.Pickup)]
public static Verdict HealOnPickup(PickupIntent p)
{
    if (p.WorldItem.Kind != "health-crate") return Hook.Continue(p);
    if (p.Player.Tank.Hp.IsFull) return Hook.Reject("already at full health");

    p.Player.Tank.Hp.Adjust(+40, by: p.Player);
    return Hook.Continue(p);
}
export const healOnPickup = before(Drops.pickup, (p: PickupIntent) => {
  if (p.worldItem.kind !== 'health-crate') return Hook.continue(p);
  if (p.player.tank.hp.isFull) return Hook.reject('already at full health');

  p.player.tank.hp.adjust(+40, { by: p.player });
  return Hook.continue(p);
});
@before(drops.pickup)
def heal_on_pickup(p: PickupIntent) -> Verdict:
    if p.world_item.kind != "health-crate":
        return Hook.continue_(p)
    if p.player.tank.hp.is_full:
        return Hook.reject("already at full health")

    p.player.tank.hp.adjust(+40, by=p.player)
    return Hook.continue_(p)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting stat-changed and pickup 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting stat-changed and pickup events.

这个 Hook 白拿到的三样东西,而每一样都是这一课如此之短的原因:

  • 拒绝是有类型的。 一辆满血的坦克会拿到 already at full health,附带一条客户端可以展示的原因,而这个血包仍然留在那里,给需要它的人。
  • 调整一个 Stat 是服务端权威的。 这不是客户端可以要求的事情,所以不用为拾取写任何反作弊例外。
  • HUD 不用被通知就会更新。 Adjust 会发出 changed;客户端本来就订阅着这辆坦克已声明的那些 Stats。你没有写过一条网络消息。

归 Extensibility 和 Entity Presets 所有。

什么变了,什么没变

之前之后
一辆受伤的坦克一直带着伤直到死掉可以靠在竞技场里移动恢复过来
Room 的代码没有仍然没有
新挂载的模块—没有:两份 Declarations 加一个 Hook
反作弊例外—没有:回血跟其他每一次 Stat 变更一样,是服务端权威的

接下来去哪儿

  • Entity Presets——这一课所倚靠的掉落生成器、世界物体和 Stat 模型,这三样都是 entity 的 preset,而不是模块。
  • Collision——层、响应,以及一次被报告的接触和一次阻挡性接触之间的区别。
  • Map——一个有效位置是怎么选出来的,以及“到得了”是什么意思。
  • Extensibility——每一个 Hook 点,按顺序,附带否决契约。
  • Tanks 里的 Leaderboard——另一个 Tanks 示例。
Examples

每日锦标赛

你会得到什么:一个带报名窗口、已布种的 Rooms 和奖励发放的每日锦标赛——完全由你已经有对应页面的那些模块上的 Declarations 和 Hooks 搭出来。这里没有一样是新概念;它就是 Leaderboards、Matchmaking、Rooms、Commerce 和 Messaging 为一个大循环(meta)组合到了一起。

第 1 步——声明这块榜,带上报名窗口和次数上限

一场锦标赛就是一份普通的 Leaderboards Declaration 加上参与约束:一个报名窗口、一个参赛人数上限、以及每周期的尝试次数上限。计分方面什么都不变——排序键、聚合方式和重置跟任何一块榜上完全一样。

Step 1: daily-tournament — score descending, daily reset, a 2-hour entry window, 64 entrants, three attempts
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same C# attributes; a Unity client reads the bracket in step 3 and cannot submit to it
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

推上去,面板就会显示一个空的赛程表,窗口已排好期。joinRequired: true 让参赛者成为一种成员关系,而不是“所有玩过的人”,所以来自非参赛者的提交会被拒绝。其余的轴列表,以及每条约束在它的边界处会做什么,参见 Leaderboards。

第 2 步——窗口打开:一支队伍加入,Rooms 被布种

报名窗口一打开,玩家排队的方式跟任何一场对局完全一样:创建或加入一支 Matchmaking 队伍,然后一次 Find 调用。Matchmaker 把这支队伍放进一个锦标赛赛程,Rooms 为这场对局布种——就是每场对局都会走的那条“分配加座位”的路径,只不过被限定在这场锦标赛的队列里。

Step 2: a party finds the tournament queue and joins its seeded room
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
                {
                    Client->Rooms->Join(Seat,
                        TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                        {
                            if (!JoinResult.HasValue()) { return; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

第 1 步里的那些上限属于这块榜,不属于 matchmaker:队列负责安排队伍,而遇上超出上限的参赛者、或者超出尝试次数的玩家的,是这块榜。第 64 名之外的第 65 名参赛者会被当作冲突拒绝,且不会挤掉任何人;同一周期里的第四次提交会回答“尝试次数已用尽”——同样是冲突,靠每日重置来解除,而不是靠去申请一个权限。

第 3 步——分数经由 on-dispose Hook 提交

Rooms 不会自己把胜者上报进一块 Leaderboard;那个环节是一个 Hook,用的还是别处那份 Extensibility 契约——输入有类型,输出有类型,没有什么塞满杂物的 context 对象。这个 Room 的 on dispose Hook(Rooms)是最后一个还拿着这场对局最终状态运行的东西,而它就在那里提交。

Step 3: an [After] hook on room dispose submits the bracket's final score
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code subscribes to the resulting rank changed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting rank changed event.

Winner 和 FinalScore 是这款游戏自己的 Room 模板在它的状态里声明的字段——平台不会往快照里加任何东西(模板状态在 Rooms 里声明)。Dispose Hook(Rooms.Disposed)把最终快照交出来,所以这场对局绝不会被重算一遍。来自 Leaderboards 的提交前 Hook 仍然会先跑——一个赛程分数跟其他任何提交一样,受同一份“纠正封顶或者拒绝”的契约约束。

第 4 步——周期关闭:奖励发放,玩家收到通知

第 1 步里的每日重置关闭这个周期的方式,跟任何一次 Leaderboard 重置完全一样,并触发 CycleClosed,带着刚关闭那个周期的标签——正是这个标签让 Hook 去读刚刚关闭的那张表,而不是刚刚打开的那张空表。

剩下的事由一个 Hook 做完:它通过 Catalog & Commerce 的权益路径发放奖品,并通过 Messaging 把结果推出去,所以没有另一个需要跑的发奖作业。一条通知是寄给一个 Actor 的,所以前 8 名就是一个八次的循环,每次把自己的 rank 参数带进模板。

Step 4: the cycle-closed hook grants an entitlement and notifies each of the top 8
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed and notification 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed and notification events.

第 1 步里的那些数字是 seed 值:LiveOps 在面板里重新调整窗口、参赛人数上限和尝试次数,而下一次部署不会悄悄覆盖掉这个改动。把它变成每周锦标赛只需要改一处——Reset.Daily() 变成 Reset.Weekly(DayOfWeek.Monday),第 2 步到第 4 步原样不动。

接下来去哪儿

How the SDK works

核心概念

其余各页在使用时不会停下来解释的那些词。 一个模块页默认你已经知道什么是 Actor、什么是切面、什么是 Room——在这里,它们每一个都得到一行定义,外加一个通往“它背后的机制真正住在哪里”的链接。在读模块参考之前把它读一遍,或者等某个词的分量超出你预期时再回来。

有三样东西太大了,装不进一个词条,各占一页:权威性——是谁在调用,以及单凭这一点就决定了什么;SDK 是怎么建起来的——SDK 由什么构成;继承与组合——模块之间如何相互构建。按这个顺序,它们读起来是一条完整的论证。

The four primitives meeting at the entity, and the modules a game actually ships coming out of it

四个接口面

每个模块正好暴露四样东西,而每个模块页都围绕它们来组织。这就是编程模型:

接口面含义
Declarations存在什么,以及它如何表现;写在代码里或写在管理面板里,两种方式得到同一个模型
Hooks你的规则,由平台在被命名的步骤上调用;作为云函数部署
Events平台告诉你发生了什么——订阅,不要轮询
Operations你请求或命令的东西,可以来自一个函数,也可以来自一个客户端

Actor

谁在发起一次调用。一次调用被允许做什么,取决于它背后的那个 Actor,绝不取决于这段代码被编进了哪个构建里——论证在权威性,机制(原子权限、组合出来的角色、InterfaceGrant)在 Access & Roles。

本文档里的 Actor 名字取自同一份目录——平台交付的那些预设。它是一组预设,不是一份封闭清单(一个 Project 会给自己的 Actors 起名),但本站每一张方案图的 actors 行、每一行“谁做什么”和每一个流程图色块,用的都是下面这些确切拼写:

player、backend-service、operator、host、moderator、schema-author、architect、bot-brain、room-owner、room-visitor、entry-validator、spectator、match-organizer、warehouse-keeper、seller——以及当一页指的是他们全体时用的 any。

一个页面还可以为某一张图额外引入一个场景角色——像 member 或 attacker 这样的描述性参与者——只要它自己的正文或“谁做什么”表格先把它介绍出来。

Runtime surface

代码在哪里运行。下面这四个标签在每一张 operations 表格里都会用到:

标签接口面
fn云函数(C#、TypeScript、Python、Go)。服务端权威;你的规则的主要归宿
cl游戏客户端(Unreal C++ / Unity C#)。API 是对称的;角色解锁得少一些
mcRoom 主机接口面:一个 master-client(一个拥有某个 Room 的客户端),或者一台在它的主机密钥下运行的 Unreal 专用服务器
adm管理面板 / CLI / MCP——SDK 与运营平面共享一个模型的地方

这正是常常被和它上面那条轴混为一谈的那条轴。 代码在哪里运行、它持有哪个 Actor 接口,是两个各自独立的问题:同一段代码放在哪里都持有同样的权利,不同的只有那份授权。

Project & Environment

一个 Project 是一款游戏的后端,带着一份 schema 和它的数据,分处彼此隔离的 Environments(dev、prod)。每一次调用都跑在某个 Project + Environment 里面。

Entity

核心名词。一个 Entity 是一份 schema Declaration 加上它的实时切面:数据 0..*、状态 0..*、RPC 0..*、Events 0..*、Hooks,以及变更历史。 一辆坦克、一扇门、一条属性条和一个任务全都是 Entities,区别只在于它们各自带了哪些切面。常见的组合以 presets 的形式交付(GameObject、Stat、Character、Interactable、Projectile)。参见 Entity。

Expected state

一次状态转移请求可以点名它所期望的状态,那么结果要么是那个状态,要么是一次拒绝。一次不点名任何状态的请求,是拿平台处理它的那一刻状态机所持有的状态来评估的——绝不是拿它被发出去那一刻的状态。

回复描述的是那一刻,对之后不作任何承诺:别人的一次转移在回复还在路上时落地,并不会让这条回复变假,也不会取消它。所以,当结果取决于原先是什么时,就把期望状态点名出来;否则别把回复当成一份能活得比这次调用更久的快照来读。

Room

一个 Room 是一场游戏会话,不是你的代码运行的地方。平台不在乎是什么在托管它:一台专用服务器、一个 master-client,或者后端自己。Room 的内部实现是我们的;你是从外部,通过 Declarations、Hooks、Events 和 Operations,从云函数和客户端来驱动一个 Room 的。参见 Rooms。

Channel & Stream

垫在一切底下的那些异步 Primitives。一个 Channel 是一个可寻址的发布/订阅主题:一个 Room、一个 Group、一个 Entity,或者你自己的。一个 Stream 是任一方向上的分块流动:文件在分块到达时被消费,查询可以流式返回,而一次 RPC 可以扇出到一个 Group 并把答案收集回来。参见 Core。

Primitive

每个模块都由这四块砖装配而成,它是其中之一:Events(声明、发出、订阅)、RPC(跨网络调用)、Data & Subscriptions(同步机制)和 Groups(一份名单,多个监听者)。一个 Room、一个聊天和一个 matchmaking 池,是同一个 Group Primitive 在不同规则下的样子。如果一个功能没法用这四者表达出来,那是一个设计缺陷,而不是要加第五个的理由。

Hook contract

到处都是同一份契约:一个 before Hook 在校验之前运行,接收类型化的载荷,可以修改它或者拒绝;一个 after Hook 在这次 Operation 提交之后运行,接收请求和结果,只能追加副作用——它永远无法让这次 Operation 失败。Hooks 是有顺序的;每一个已注册的平台步骤都可以带上它们。参见 Extensibility。

Delta & Revision

客户端以 Deltas 的形式接收状态:只有变了的字段,并且是相对接收方最后确认的那份状态编码的。每条记录都带一个 Revision;条件写入在不匹配时拒绝。一个版本化概念同时服务于同步、并发和历史。参见 Data & Subscriptions。(Unreal 所说的 replication——哪个客户端看到哪份状态、多久看一次——住在这里,以及 Visibility 和 Prediction & Lag Comp。丢掉一台主机之后什么还在那一页说的是另一件事:哪台机器拥有某个 Entity,以及下一个拥有它的是谁。)

Tick

Rooms 按固定步长模拟。每一次状态变更都盖上它所属的 Tick 戳;同步、预测、延迟补偿和历史全都按 Tick 计数,而不是按墙上时钟。数据带着它真正的事件时间——正是这一点让回溯和和解变得精确。参见 Prediction & Lag Comp。

How the SDK works

权威性

权威性是一种抽象,不是 SDK 的两个构建版本。 这里没有客户端 SDK,也没有服务端 SDK。只有一个 SDK,而某次调用被允许做什么,取决于发起它的那个 Actor。

一个 master-client 既不是客户端也不是服务器

一台创建了一个 Room 然后又运行它的玩家机器——一个 master-client——持有 room-owner 的那些接口,仅此而已。它不是服务器:它做不到服务器能做的一切。它也不是一个普通客户端。

一台专用服务器是同一个形状的另一面:同一个客户端,只是没有渲染,也不需要一个单独的 SDK。把这两者分开的是信任,不是构造,而信任是由授权承载的。

接口跟着 Actor 走,而不是跟着某一端走

一个模块并不暴露“客户端 API”和“服务端 API”。它暴露的是一个 room-owner 能做什么、一个 entry-validator 能做什么、一个 seller 能做什么。客户端和服务端是管道;Actors 才是领域。在每个模块页里面,接口面也是按同样的方式分组的——这一组是给这些需求的,那一组是给那些需求的。

一个角色既是权利,也是分类

这里只有恰好一个维度。一个角色承载着某个 Actor 被允许做什么,同时它也是你用来说明某样东西是寄给谁的方式。它旁边没有第二条标签或标记的轴:一样东西要声明,一样东西要检查,一样东西要在管理面板里读。

whoami 是代码询问的方式。它报告的是当前这个 Actor,以及这个 Actor 此刻解锁的那些接口——而不是构建时烙进二进制里的一份静态清单。

人们会压成一条的两条轴

问题答案
这段代码在哪里执行?一个云函数、一个游戏客户端、一个 master-client 或专用服务器主机
它持有哪个 Actor 接口?player、room-owner、entry-validator、seller、moderator、backend-service、…

摆成一张网格,这两条轴是相互独立的,而且每一格都到得了:

云函数游戏客户端Room 主机管理
player✓✓✓—
room-owner✓✓——一台玩家自己的机器,在做托管✓—
backend-service✓—✓✓

被强调的那一格,正是客户端/服务端的划分叫不出名字的那一个:代码跑在一个客户端上,干着服务器的活。一条从顶行正中间劈下去的分界描述不了它,于是它被叫作一个例外。在这里它有名字——room-owner——而它跟其他任何一份授权一样,就是一份授权。

任何组合都是合法的。一个云函数不会自动就有特权,一个客户端也不会自动就受限:权利来自授权,而授权是声明出来的。这段代码的权利不管在哪里运行都是同一份——不同的只有它被授予了什么。

撤销无需重新签发凭据即可生效

一份凭据点名的是一个身份。它不携带一份角色清单。角色是在服务端、按每次请求解析的,这意味着客户端从不持有它自己权限的凭证,撤销之后也就没有任何过期的东西可以继续出示。

有两个后果,各自写在它们该在的地方:

  • 撤销一个角色无需重新签发凭据就会生效——参见 Auth & Players。
  • 它最迟会在权利缓存所声明的陈旧上界处变得可观察。我们不承诺即时。

权威在哪里是被声明的,而不是被推断的

  • 一个 Room 类型声明它的权威模式,而且没有默认值:要么由我们的模拟来跑这个 Tick,要么由一个外部权威来跑——工作室的游戏服务器,或者作为 master-client 的一台玩家客户端。那就是 Rooms 里的“谁在跑 Tick”。
  • 一个外部权威在结果这件事上被信任到什么程度,是这个 Room 类型上一份单独的 Declaration——接受它、用一个 Hook 核对它,或者不接受它。同样没有默认值。
  • 哪份凭据解析成哪个角色,以及什么是主机密钥,都在 Access & Roles。
How the SDK works

Access & Roles

角色是组合出来的,从不硬编码。 原子权限组合成角色;角色把数据一直管到行和列,并决定一个构建到底能看见哪些模块接口。它取代了客户端/服务端的密钥划分:一份凭据点名一个身份,它的角色按每次请求解析。

Who addresses Access, the 4 things it provides, and the 2 modules it builds on

何时使用

  • 你需要一份比“客户端”或“服务端”更窄的凭据——组合出来的角色在它背后按每次请求解析。
  • 数据访问必须停在行和列上:区域限定、PII 掩码、只读的外包人员。
  • 一个构建应该只看到它的角色所解锁的那些接口——对一名访客来说,踢人/关闭压根就不存在。
  • 你的 UI 必须诚实地把按钮置灰——CanI 求值的正是服务器将要执行的那份策略。
  • 不必用它:交付的那些预设(player、room-owner、seller……)已经和你的 Actors 对得上时——每个模块默认都尊重它们;完整目录在核心概念。

谁做什么

Actor在本页
operator声明角色和策略,设定行/列上限,授予角色,签发密钥
match-organizer下面这个流程里的赛事工作人员:持有一份组合出来的密钥,把关报名,但不能退款
every actor动手之前先查 CanI;只看得到自己解锁的那些接口

一览

Declare entry-validator with row/column limits, grant it, then check CanI before acting
[Role("entry-validator")]
public class EntryValidator
{
    [Allow(Rooms.Membership.Administer)] public Permit GateEntries;
    [Allow(Data.Records.Read, table: "player_profile", rows: "banned == false",
           columns: "id, display_name")] public Permit SeeProfiles;
}

await PlayServ.Access.Grant(staffId, Roles.EntryValidator, Roles.MatchOrganizer);
var key = await PlayServ.Access.IssueKey(staffId);   // the credential names no roles

// any actor, before attempting an operation:
if (await PlayServ.Access.CanI(Commerce.Orders.Administer)) Hud.ShowRefund();
@Role('entry-validator')
export class EntryValidator {
  @Allow(Rooms.membership.administer) gateEntries: Permit;
  @Allow(Data.records.read, { table: 'player_profile', rows: 'banned == false',
                              columns: ['id', 'display_name'] }) seeProfiles: Permit;
}

await playserv.access.grant(staffId, Roles.entryValidator, Roles.matchOrganizer);
const key = await playserv.access.issueKey(staffId);  // the credential names no roles

// any actor, before attempting an operation:
if (await playserv.access.canI(Commerce.orders.administer)) hud.showRefund();
@role("entry-validator")
class EntryValidator:
    gate_entries = allow(rooms.membership.administer)
    see_profiles = allow(data.records.read, table="player_profile",
                         rows="banned == false", columns=["id", "display_name"])

await playserv.access.grant(staff_id, roles.ENTRY_VALIDATOR, roles.MATCH_ORGANIZER)
key = await playserv.access.issue_key(staff_id)   # the credential names no roles

# any actor, before attempting an operation:
if await playserv.access.can_i(commerce.orders.administer):
    hud.show_refund()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSRole = "entry-validator")
struct FEntryValidator
{
    GENERATED_BODY()

    UPROPERTY(PSAllow = (Atom = "Rooms.Membership.Administer"))
    FPSPermit GateEntries;

    UPROPERTY(PSAllow = (Atom = "Data.Records.Read", Table = "player_profile",
                         Rows = "banned == false", Columns = "id, display_name"))
    FPSPermit SeeProfiles;
};

// granting is an operator act; a build checks what its identity resolves to
const FPSActor Me = Client->Whoami();   // which interfaces this actor unlocks
Client->Access->CanI(TEXT("Commerce.Orders.Administer"),
    TPSOnResult<bool>::CreateWeakLambda(this, [this](const TPSResult<bool>& Result)
    {
        if (!Result.HasValue()) { return; }
        if (Result.Value()) { Hud->ShowRefund(); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same `[Role]` / `[Allow]` declaration as the server tab, on the Unity 2021.3 runtime
var me = playserv.Whoami();               // which interfaces this actor unlocks
if (await playserv.Access.CanI(Commerce.Orders.Administer)) hud.ShowRefund();

一个 atom 是一个二元组——一个资源,加上四个动词之一:read、write、execute、administer。动词集合是固定的,而一个装不进去的情况要拆的是资源,而不是把这份清单撑大。这就是为什么把关别人的入场是 Rooms.Membership.Administer,而不是一个自成一格的 ValidateEntry 动词:作用于另一个 Actor 的成员关系是管理,而你自己加入是同一个资源上的 Rooms.Membership.Write。

模型

数据 ACL 是 role × operation × row predicate × field mask——一个模型,不管它是写在代码里、通过 API 写的,还是在面板的角色网格里写的,都完全一样。

Access 由什么构成。

术语是什么
atom一个资源 × 动词二元组。四个动词是 read(获取、筛选、订阅)、write(创建、修改、删除,以及为自己行事——加入、离开)、execute(调用一个函数、施放一个 ability)和 administer(作用于他人:踢人、关闭、强制状态转移)
role一组被命名的 atoms。它可以包含另一个角色,而包含关系里出现环是配置错误,而不是运行时去解的东西
role preset在 atoms 之上交付,并且对已经部署的消费方保持原样继续可用。它是一个起点,不是约束:一个 Project 用同样这些 atoms 声明自己的角色
row predicate哪些行——一个基于会话值的布尔谓词
field mask哪些字段,按角色并且按 operation 声明。一个角色不被允许读的字段是根本不返回,而不是返回空

一份凭据解析成什么。

凭据它解锁什么
player key一个引擎构建携带它;它背后的玩家随登录到达,而这个构建看得到每张 operations 表格里的 cl 行
host key一台专用服务器或一个 master-client 持有它,而它的角色解锁 mc 那些行
pushed code以这个 Project 的 backend-service 角色运行——那正是一块 Leaderboard 的 Authoritative = true 所检查的东西
a registered hook不额外授予任何东西:你的函数保持它部署时所用的那个角色

标签图例(fn / cl / mc / adm)归核心概念所有。

每一次检查都成立的事。

总是成立是什么
a credential不携带角色清单:它点名一个身份,而角色在服务端按每次请求解析。一次撤销会立刻让缓存的解析结果失效,而不是等它声明的陈旧上界走完
delegation改变的是范围,绝不是能力:代表一名玩家行事,改变的是哪些行可见、以及一次写入归属于谁,而不会授予这个 Actor 本来没有的任何 operation
the verb回答的是“什么类型的效果”,而谓词回答的是“哪些行”。如果两种情况的区别只在于那是谁的行,那就是一个谓词;如果效果本身不同,那就是另一个 operation,而且很可能是另一个动词——这也是为什么“踢人”是 administer,而不是配一个宽谓词的 write
a hidden row回答 not found:一次拒绝绝不能变成一个“存在与否”的神谕
an owner无论谓词还说了什么,它总是看得到自己
visibility不是安全性——一项通道优化和一项权限是不同的机制,谁也代替不了谁
a disabled module没有接口面:一个模块是否启用是构建的属性,所以 codegen 对一个被禁用的模块什么都不生成,而一次用不了的调用是编译错误,而不是运行时的拒绝
a module's surface跟着 Actor 走而不是跟着某一端走(权威性论证了它,而这里是它的机制):一个 room-visitor 构建看得到加入、离开和读取,一个 room-owner 构建还额外看得到踢人、关闭和配置,而 whoami 报告当前这个 Actor 解锁了哪些接口
A credential resolving to an identity, to roles composed from atomic permissions, and out to both the interfaces you can see and the rows you may read

谁来发放一个角色。 给一名玩家授予和撤销一个角色,以及这个 Project 为新玩家声明的默认角色,都是 Auth & Players 上的 operations——那个模块拥有身份,而一个角色是由凭据里的身份解析出来的。本页拥有一个角色是什么;那一页拥有把它交出去这件事。

错误

  • 被谓词藏起来的东西回答 not found,而不是 forbidden——否则这次拒绝本身就告诉了调用方那样东西存在,而那恰恰是藏起来要防的。
  • 调用方不持有的权利回答 forbidden,前提是那个对象的存在本身不是秘密;而且它会点名缺的是什么,而不是干巴巴地失败。
  • 掩码之外的字段在答案里是缺席的,而不是在场且为空:一个空值和一个被掩掉的值将无从分辨。
  • 一个角色直接或者经由一条链包含了它自己,是配置错误——在声明层面就被拒绝,而不是留到运行时去解。
  • 委托绝不拓宽能力:一次这个 Actor 以自己名义做不了的调用,以一名玩家的名义去做同样会被拒绝。

限制

每一条上限都点名它在边界处的行为;数字会随平台限制那一章一起落地。

  • 在一个行谓词之下一次选择的规模是有界的,而这个 ACL 模型会把那个界声明出来,而不是等着去发现它。超过上限的一次读取,得到的是上限那么多行,外加一个说明它被截断了的标记,绝不会是一页悄无声息地变短的结果。
  • 一份已解析权限的陈旧上界是声明出来的,而一次撤销不会等它走完——它立刻让其失效。

用户流程

一个赛事组织者的密钥,从角色组合一直到一次实时的权限变更。

How the SDK works

SDK 是怎么建起来的

有两个问题常常被人搞混,而两个都有简短的答案。SDK 是怎么写出来的——为什么同一个想法在 Python 里和在 Unreal C++ 里看上去略有不同。SDK 是怎么运行的——在你的调用和线路之间垫着什么。本页把两个都回答一次,好让任何模块页都不必再答。

从一般写到特殊

One design with two narrowing escapes: common principles, then only what a language cannot express that way, then only what an engine reshapes

这个 SDK 是一套设计加两道收窄的出口,而顺序本身就是规则:只有上一层实在承载不了,某样东西才会往下沉一层。

层级这里住着什么
共通的原则在每一种绑定里都完全一致:行为以特性的形式声明在它所描述的那样东西旁边;你推上去的每一份 Declaration 都是管理面板会渲染出来的 Declaration;你的代码只面向模块,别的都不碰。
一门语言的形状只放那些语言的范式无法用共通方式表达的东西。C# 有特性,Python 有装饰器——同一份 Declaration,用各自语言本来就用来表达这个想法的写法写出来。一门没有这类构造的语言会用另一种方式承载同一份 Declaration,而那个载体会在它适用的地方被点名,而不是被默认。
一个引擎的形状只放游戏引擎在它的语言之上重塑的东西。Unreal C++ 不是普通的 C++——它有自己的对象模型和自己的构建期反射,所以一份 Declaration 在那里骑在引擎自己的反射宏里面,就在那个宏本来就接收说明符的位置上。Unity 的 C# 也不是服务端的 C#:更老的运行时,更小的基础库。

自上而下读一遍,就明白为什么每个示例上的那六个页签并不是六套不同的 API。它们是一套 API,用六种方式拼写出来,而你能看到的那些差异,就是下面两层透出来的样子。

它是怎么运行的,从你的代码往下

你的游戏代码看到的是模块。这不是为文档做的简化——这就是最上层的全部契约。

  • 模块是你所寻址的东西。它们构成一张图,而不是一棵树,而这么做换来了什么,是 Inheritance & Composition。
  • 四个 Primitives 是模块被装配起来的原料——Events、RPC、Data & Subscriptions、Groups。一个 Room、一个聊天和一个 matchmaking 池,是同一个 Group Primitive 在不同规则下的样子。如果一个功能没法用这四者表达出来,那是一个设计缺陷,而不是要加第五个的理由。
  • hub 垫在下面,而你从不调用它:依赖注入、模块挂载、用户会话、状态恢复,以及消息服务质量。它在深入内部上被点名一次。
  • 传输适配器坐在最底下,每种协议一个,而 hub 把它们彻底藏了起来。它们会有好几种——WebSocket、我们自己的 UDP、HTTP——而一次调用由哪一个承载,既不由你的代码决定,也不被你的代码察觉。

一个模块关于投递唯一会告诉你的事,是它的服务质量——至少一次,或者至多一次。这些字节是怎么到那儿的,其余一切都被有意地不让你知道,因为那正是我们保留把它变快的权利的那一部分。

你从中得到什么

  • 一个 SDK,而不是一个客户端的加一个服务端的。 一次调用被允许做什么,是那个 Actor 的授权,不是一个构建开关。那就是权威性,也是本页上后果最深远的那一个决定。
  • 一份 Declaration 是一切的输入。 把它推上去,类型化 API 就出现了,管理面板会渲染它,而每种绑定的 codegen 也随之跟上。参见 Schema as Code。
  • 模块靠组合而不靠继承。 具体怎么组合,以及“继承”在这里诚实地讲究竟意味着什么,都在继承与组合。
How the SDK works

线程、生命周期与测试

循环归你所有。我们只往恰好一个地方投递,而且绝不背着你来。 这个 SDK 不会起任何需要你知道的线程,不会递给你任何锁,并且只从你在启动时选定的那一个上下文调用你的代码。从你喜欢的任何线程调用我们;我们只从一个线程调用你。

一个投递上下文,而循环归你所有

Calls go in from any thread of yours; deliveries come back on exactly one context you chose, one after another

一个实例声明恰好一个投递上下文——它所有处理器运行的那唯一一个地方。它在你初始化时定下,并且在这个实例的整个生命周期里不会改变。一个 Event、一次数据 Delta、一次调用的结果:它们全都到达那里,别处都不到。

它有两种形态,你在启动时挑一种:

  • 由你来泵。 运行时自己什么都不做;你从你自己的循环里把待投递的东西抽干。这是引擎想要的形态——投递落在游戏线程上,落在你选定的一帧里。
  • 由我们持有。 运行时持有一条专用的执行线程。这是一台游戏主机或者一台专用服务器想要的形态。

两者谁也不是谁的兜底,而且不存在牵涉线程池的第三种选项。承诺只有一个上下文,重点就在于你永远不必去问我们开了几条线程。

上下文从来不是一个参数。 没有哪个处理器接收“我现在在哪条线程上”这样的参数,也没有什么可以去查询。你的处理器在哪里运行,是契约的一项属性,而不是这次调用的数据。

启动和停止都是显式的

初始化是一次由你发起的调用,而它以一个结果作答。没有任何东西会在首次使用时惰性初始化——那是被禁止的,而不只是不推荐;理由值得写一句:惰性启动会把“一个被禁用的模块在哪里可见”这唯一的一个地方,挪到碰巧最先发生的那次任意调用上,在那里它读起来就像是那次调用失败了。

一个被禁用的模块在启动时就被点名,而那个结果会说明发生的是两件事中的哪一件:整条依赖链都关掉了,或者你在少几样东西的情况下运行,外加一份用不了的清单。不存在悄无声息的第三种情况。

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

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

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

关闭不会取消任何东西。 这是大多数 SDK 养成的习惯在这里明确出错的那一个地方。关闭是显式的、彻底的、幂等的——它成功之后,这个实例的任何处理器都不会再被调用——但它对已经在途的工作什么都没说。你在关闭之前发起的一次 operation,仍然可以通过那次 operation 所点名的手段被查到。如果你需要知道一笔购买有没有走通,关闭不是你用来搞清楚这件事的方式。

每个句柄都有一个被声明的终点——而且那绝不是垃圾回收器

一个订阅、一个延迟工作描述符、一个会话:每一个都是一个句柄,而每一个都恰好有一个由契约点名的终点。释放是幂等的,所以释放两次不是错误。

有三个后果很容易搞错:

  • 终点绝不是终结器、析构函数或者作用域。 一个你随手丢掉的句柄仍然开着。那是你代码里的一个 bug,而不是我们会悄悄回收的东西——因为一个依赖语言的生命周期,在每种绑定里都会是不同的生命周期。
  • 在句柄的终点之后使用它,是一次被声明的拒绝,带一个代码。不是一个空结果,不是未定义行为,也不是一个什么都不带、你无从下手的通用“对象已释放”错误。
  • 连接断开不是一个句柄的终点。 一个订阅能挺过断连,并在重连之后继续接收。句柄因契约点名的那些理由而终结,而丢网络不在其中。

没有哪个句柄能活得比签发它的那个实例更久:一旦你关闭,它给过你的每一个句柄都到了它的终点。

从处理器里发起调用没问题;在处理器里面等待则不行

从你的任意线程调用这套接口面。每个句柄都是自由线程的,而这是一项承诺,不是今天这个构建碰巧具备的属性。你永远不会拿到我们的锁、在我们的栅栏上等待,或者被告知要“在锁下”调用什么——任何同步原语根本就不属于这套接口面。

同一个实例的处理器是串行化的:任何两个都不会同时运行,而且同一条流里面的顺序会被保持。所以一个处理器不需要自己去加锁。

串行化不等于去重。 顺序是一项承诺;一条消息被投递几次是另一项,声明在消息类型上。在至少一次之下你会看到同一条消息两次,而始终随它一起传的那个去重键,就是你用来分辨的东西。

从一个处理器里面发起一次 operation 是合法的,而且不会死锁。不过它的结果绝不会在那同一个处理器里面到达——它会作为同一个上下文上的一次独立投递回来。往里走是允许的;在里面掉头则不行。

阻塞投递上下文是被禁止的,而这条禁令不是建议。等网络、等别人的锁、同步地等你自己那次调用:在处理器里面全都被禁止。这条禁令有一个症状——一个把上下文占住、超过它所声明预算的处理器,要么产生一次被声明的投递降级,要么产生一次被声明的拒绝。它绝不会产生的,是一次让你在某位玩家的会话里才发现的、悄无声息的变慢。

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

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

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

“取消”其实是两件不同的事

在大多数语言里是一个词,在这里是两个 operation,而且这个区别是可观察的:

你想要的是什么
别再往我这儿投递了本地的。总是成功,连接断着也一样。释放一个订阅就是这一种。
把那件工作停掉一次发给平台的请求。幂等,而且它对那件工作到底发生没发生不作任何承诺。

第二种是人们会搞错的那一个。取消一件已被接受的工作是一次可能来不及送达的请求——就跟超时一模一样,而超时同样不等于“它没有生效”。在任一种取消之后,一次已经开始的非幂等 operation 的结果仍然可以被查到,用那次 operation 所点名的手段。

而这两种失败是可以分辨的:取消一次根本没到我们这儿的调用,是一次本地失败;取消一件我们已经接受的工作,会给你一个来自已声明集合的终态。

你收到的是过去的一份副本

一个投递给你的处理器的值在那之后不会再变。我们绝不会把一个指向我们自己状态的活引用交出去,所以你手上拿着的任何东西,都不会在你代码的两行之间发生变化。

因此,把一个已投递的值留到处理器之外是安全的——但你留下的是对某一刻的一次观察,而不是一扇朝向当下的窗。Deltas 在到你这儿的路上可能被合并过,所以你保存下来的一串值并不是一部“发生了什么”的历史。

你同样从不拥有我们的缓冲区。没有借出再归还,没有先组装再发送:修改已声明的状态就是那次网络 operation。

测试:一份内存实现,而不是一个 mock

有一份完整的内存实现——同一套接口面、同一组已声明的结果、没有网络。它是你依赖的一样单独的东西,而不是生产运行时上的一个开关。

  • 它不是残缺的。 一个它不支持的 operation 会以一个已声明的代码被拒绝,绝不会用一个编出来的成功作答。一个对着它通过的测试,是有理由地通过的。
  • 时间归你。 那些已声明的期限——一个工作描述符的寿命、一次预留、一个保留窗口——是靠推进一步来抵达的,而不是靠睡眠。
  • 确定性是被声明且有界的:一条流内部的顺序、已声明的投递模式、可控的时间。浮点确定性不在承诺之列,所以一次完整的模拟在这里同样不会被重放。

它和一个 mock 的区别正是重点所在。一个 mock 检查的是你有没有调用你打算调用的东西。而这个检查的是你调用的东西说不说得通。

你安装什么,以及版本下限

核心是一个单元;可选模块是各自独立的单元,每一个都带一份已声明的构成和一份已声明的必需依赖清单。加一个单元绝不会改变另一个的接口面——一个模块挂在它的 Declaration 说的地方,所以不会因为你在它旁边装了什么,别处就冒出或者消失了什么。

如果一个可选单元被引用了却加载不了,那是初始化的一个已声明结果——跟一个被禁用的模块被上报的是同一个地方。绝不会是一个悄无声息什么都不做的桩。

每一种绑定都声明它所针对构建的最低运行时版本。低于它,你在初始化时就会得到一次拒绝,而不是部分可用:否则一个太老的运行时会在它所缺的第一项能力上崩掉,而那个位置在你代码里是任意的,并且通常是在某位玩家的机器上,而不是你的。抬高那个下限是一次破坏性变更,走的流程和其他任何一次一样。

接下来去哪儿

Building blocks

Core

Core 就是你唯一创建的那个对象,其他一切都挂在它上面。 一个 key 进去,你就拿到了上下文、身份、类型化的失败、追踪和批处理。每一次模块调用都要经过它,而没有哪个模块会自带一份自己的版本。

Who addresses Core, the 4 things it provides, and the 1 module it builds on

何时使用

  • 你需要知道你是谁、在哪里——身份、角色、已解锁的模块、Project、env、region,全在你手上那一个对象上。
  • 一个云函数必须以某名玩家的身份写入——这次写入归属于那名玩家,而留下的记录里同时写着双方:函数和玩家。
  • 重试绝不能重复生效——批量 operations 携带一个幂等键。
  • 一次失败必须可分支、可搜索——每一次抛出都是一个带稳定代码的类型化 Problem。
  • 不必用它:你要的是消息、调用或者状态时——那些是 Primitives:Events、RPC、Data & Subscriptions。

谁做什么

Actor在本页
any actor通过 Whoami 读取身份、上下文和角色
backend-service以某名玩家的身份行事;把幂等的 operations 批起来
operator读取失败或被重试调用的追踪

一览

One handle: Whoami, the ambient context, and a batch that retries safely
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});
const me = playserv.whoami();             // identity, roles, unlocked modules
const env = playserv.context;             // project · env · region

// retries never double-apply: the batch carries an idempotency key
await playserv.batch(orderId, (b) => {
  b.inventory.grant(playerId, 'starter.pack');
  b.inventory.grant(playerId, 'starter.emote');
});
me = playserv.whoami()                    # identity, roles, unlocked modules
env = playserv.context                    # project · env · region

# retries never double-apply: the batch carries an idempotency key
async with playserv.batch(key=order_id) as b:
    b.inventory.grant(player_id, "starter.pack")
    b.inventory.grant(player_id, "starter.emote")
Coming soon — Go

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

const FPSActor Me = Client->Whoami();               // identity, roles, unlocked modules
const FPSPlatformContext Env = Client->Context();   // project · env · region

// retries never double-apply: each keyed operation is safe to repeat
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(PackGrantId), PlayerId, PSKeys::Item::StarterPack);
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(EmoteGrantId), PlayerId, PSKeys::Item::StarterEmote);
// 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
var me  = PlayServ.Whoami();              // identity, roles, unlocked modules
var env = PlayServ.Context;               // project · env · region

// retries never double-apply: the batch carries an idempotency key
await PlayServ.Batch(key: orderId, b =>
{
    b.Inventory.Grant(playerId, "starter.pack");
    b.Inventory.Grant(playerId, "starter.emote");
});

身份住在 Core 自己里面。任何调用里都不会出现 session 或 ctx 参数。

模型

每个错误都携带什么。

字段是什么
code这次拒绝的机器可读名字,而且它是稳定的。这套词汇是平台既有代码的一次投影:为一个平台已经命过名的拒绝新增一个代码,是被禁止的
category这次拒绝所属的那一类,也正是它说明重试到底有没有意义
trace identifier这一次发生的标识符,总是在场,本地错误也不例外,所以联系支持时永远不需要先把故障复现一遍
explanation给人读的文本,而且它不稳定:标题和说明随时会变,也会被本地化
per-field errors一次校验拒绝所携带的清单——每个被拒字段的字段名、代码、消息

消费方按代码和类别分支,绝不按给人看的文本——不比较,不取子串,不解析。一个只能拿到消息的错误,是那种绑定的缺陷,而不是一种需要你绕开的形状。

三种来源,而它们不是一回事。

来源发生了什么
platform它以一次拒绝作答,带着平台目录里的一个代码
localSDK 在发出去之前就拒绝了,用的是它自己公布的那套词汇
unknown这次调用已经发出去了,而没有答案回来。既不是“平台说不行”,也不是“我们根本没问”

每一次拒绝都成立的事。

总是成立是什么
a refused operation applied nothing原子性是平台的义务,不是你的:普通错误分支上不需要做补偿性读取。恰好有两种情况例外,而且两者都在各自出现的地方说明了——一次超时,它的结果是未知的;以及一次按元素语义处理的批量操作
the delivery path不会改变这个错误:不管这种绑定是抛出、返回一个结果值,还是在一个订阅上回调,到你手上的都是同样的代码、类别和来源。一条比另一条承载得更少的路径,是那种绑定的缺陷
a timeout不是一个结果:它是上面那第三种来源,而拿它怎么办是按 operation 声明出来的,不是靠猜

Core 承载的是上下文,不是消息。 发出事实和订阅事实是 Events 这个 Primitive;调用——请求/响应、单向、Group 扇出——是 RPC 这个 Primitive;状态、订阅和流式读取是 Data & Subscriptions 这个 Primitive,通过 Entity 来寻址。这三者扇出到的那些受众,是第四个 Primitive,Groups。有大小的传输(上传、下载)浮现在 Files & UGC。挂载语义——命名空间、挂载时的冲突拒绝——住在深入内部。

错误

The same call, refused: branch on the code, never on the text — rate_limited carries the moment a retry is allowed
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}
try { await playserv.inventory.grant(playerId, 'starter.pack'); }
catch (p) {
  if (Problem.code(p) === 'rate_limited') hud.retryAt(p.retryAfter);
  else throw p;
}
try:
    await playserv.inventory.grant(player_id, "starter.pack")
except Problem as p:
    if p.code == "rate_limited":
        hud.retry_at(p.retry_after)
    else:
        raise
Coming soon — Go

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

// UE builds run without exceptions — the completion carries the result, read explicitly
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::StarterPack,
    TPSOnResult<void>::CreateWeakLambda(this, [this](const TPSResult<void>& Result)
    {
        if (Result.IsRefused() && Result.Refusal().Code == FPSFailureCode::RateLimited)
        {
            Hud->RetryAt(Result.Refusal().RetryNotBefore);   // TOptional<FDateTime> — an instant, not a delay
        }
    }));
// 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
try { await PlayServ.Inventory.Grant(playerId, "starter.pack"); }
catch (Problem p) when (p.Code == "rate_limited")
{
    Hud.RetryAt(p.RetryAfter);
}

一个没有那个角色的调用方看到什么。 fn 和 adm 那些行是被拒绝,而不是被降级:一个调用“以玩家身份行事”、发出一条追踪或者读取一条追踪的玩家或客户端会话,会拿到一个代码为 forbidden 的 Problem——凭据是有效的,它背后的角色不带这项权利,而用同一个幂等键重复调用什么都不会改变。不存在一个用更少权利运行、返回更少东西的缩水变体。

每一次失败都是一个带稳定代码的类型化 Problem:跟线路契约所记录的是同一批代码,所以客户端可以按它们分支,人也可以搜到它们。

限制

限制是在准入时施加的。 一次已经被接受的调用,说明它已经过了这道限制,并且会被执行下去,不管它排多久才被处理;一次落在之后某个调用上的拒绝,对已经准入的工作毫无影响。所以一个填满了的队列是一个会跑完的队列——因为邻居被拒绝了就去重试那次已被接受的调用,正是你把活干两遍的方式。

你是靠被拒绝才知道一条限制的,除此之外没有别的可读。 SDK 既不暴露当前生效的值,也不暴露还剩多少余量,更不会在接近某条限制时发出警告,而且关于限制的任何东西都绝不会摆到玩家面前。那次拒绝携带的就是全部:

  • 类别,正是它说明重试到底有没有意义
  • 那是谁的限制
  • 什么时候允许重试,以及在哪个窗口上

按这些来分支。没有计数器可轮询,也没有额度可展示。

用户流程

一次失败的调用,从抛出一直到一位 operator 读到的那条追踪。

Building blocks

Events

一个 Event 是“某件事发生了”这个事实,投递给所有应该听到它的人。 用它来表达那些只发生一次、并且无法从某个当前值补追回来的事——一次射击、一笔购买、一次进入 Room。

Who addresses Events, the 5 things it provides, and the 1 module it builds on

何时使用

  • 某件事发生了,而别人必须对它作出反应——开了一枪、锁上一扇门、结束一场对局。
  • 受众是变化的——同一次发出可以到达一个小队、一个 Room,或者一个 Actor,由类型所声明的目标决定。
  • 你想要带自动补全的类型化处理器——一个已声明的 Event 会变成它接口面上的 send. 和 on.,各带各的契约。
  • 这个事实一小时后还必须可读——把类型声明为 retained,然后按时段把它读回来。

谁做什么

Actor在本页
schema-author用 [Event] 声明 Events,推送 schema
any actor通过 send. 发出,通过 on. 订阅

一览

Declare RallyCall once; emit with send., react with on.
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

一个声明在某个模块或 Group 内部的 Event 只在那里浮现:squad.send.rallyCall 之所以存在,是因为 rally_call 是为小队声明的,而这次发出到达的是小队成员。一个模块向外发出的 Event 是它已声明契约的一部分;调用方永远不会知道那些没有声明的。

模型

一个 Event 类型声明什么。

声明是什么
name一个显式的线路名称,是声明出来的,而不是从符号推导出来的
payload一次发出所携带内容的 schema
target这个类型的发出会去哪里:一个 Entity 实例、一个 Group、一个 Room,或者全局上下文。一个 Group 目标就是批量投递——一个信号,多个接收者。一个变化的收件人用一个 Group 来表达,绝不用发出时传进去的一个地址
clocksim_time 或 timestamp,绝不两者兼有——sim_time 用于模拟内部的事实,它们会参与预测、延迟补偿和回溯;timestamp 用于模拟之外的事实,比如一笔购买或者一次登录
retentiontransient——到达发出那一刻正在订阅的人,且不被存储;或者 retained——被存储下来,并按类型和时段读回,而不是走 Data & Subscriptions 所承载的那套查询接口面。这是声明出来的,绝不从事件的种类推断
term在一个 retained 类型上:它被保留多久,以及到期时会发生什么。“永远”不是可选值之一
delivery至多一次、至少一次或者恰好一次——声明在类型上,所以订阅者永远不必去问某次发出用的是哪一种;“恰好一次”会说明它在什么边界之内成立
context这个类型被声明所在的上下文,全局的或者局部的。一个全局声明的名字在局部上下文里可见;一个局部声明的名字在上面不可见。不管哪种,一个模块发出什么都是它的契约——调用方永远不会知道一个未声明的 Event

一次发出携带什么。

字段是什么
type那个已声明的 Event。两次发出绝不会合并:两枪就是两个 Events,第二个不会把第一个吸收掉——这正是把一个 Event 和 Data & Subscriptions 所承载的 [Sync] 字段区分开来的地方
payload符合该类型的 schema
source发出它的那个 Actor,以及当它是由一个 Entity 发出时的那个实例。一个由客户端发出的 Event 是一项主张,不是事实:权威那一侧会在任何东西依赖它之前先核对它
stamp打在该类型所声明的时钟上
dedup key在每一种投递模式下都在场,因为重复投递在所有模式下都可能发生——一次传输层重复,或者对一个 retained Event 的第二次读取
cause key在一个平台因为另一个平台 Event 而发出的 Event 上:它所源自那件事的 id,这样一条链就是按键重建出来的,绝不靠比较时间戳

一个订阅持有什么。

持有是什么
event它所绑定的那个已声明类型
surface它被取用的那个节点,位于该类型所声明的 target 之内——订阅者那一半的受众
handler按载荷类型化
position它从哪里续上,是声明出来的,所以一次重连不会悄无声息地从“现在”重新开始。空档里错过的东西不会被重放:一个 transient Event 是不可恢复的,只有一个 retained 的才能被读回

每一个 Event 都成立的事,不管类型声明了什么。

总是成立是什么
audience绝不由发送方枚举:它是该类型所声明的 target 收窄到正在订阅的那些人,然后再由 Access & Roles 把关——发布和订阅是两项分开的权利,谁也不蕴含谁,而且即便类型本身可见,一条流也可能被一个谓词关掉。一个能列出接收者的发送方,就得把 Groups 和 Data & Subscriptions 已经知道的东西再复制一遍
phases发出,然后投递——没有别的了。一个 Event 没有状态机:它只发生一次
ordering在一条流内部有承诺,而对 Events 来说一条流就是一个发出实例:来自同一个实例的两个 Events 按发出顺序到达。流与流之间不承诺任何形式的顺序——两个实例之间不承诺,同一次变更的一个 Delta 和一个 Event 之间也不承诺
crossing streams当需要跨流的顺序时,机制是声明出来的,绝不靠假定:要么把这些消息收进同一条流,要么在载荷里带一个因果戳
gap detection在那些允许丢失的模式下,订阅者会知道有一段空档,而不是悄无声息地跳过去

一个事实在投递之后是否被保留,是它 Declaration 上的一个槽位,而不是在发出时作的决定——所以同一个类型总是以同样的方式被保留,没有哪个调用方需要记住哪次调用是哪一种。

TransientRetained
到达那一刻正在订阅的人那些人,外加之后才来的订阅者
之后没了保留一段已声明的期限
可读回否是,在这段期限内
超出期限之后—一次选择会被拒绝,而不是答以空
A retained event: declared with its term, read back by period
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

错误

  • 发布和订阅是两项分开的权利,谁也不蕴含谁。一次没有相应权利的订阅回答 forbidden,而不是 not-found——这个类型在模块的已声明契约里,所以没有什么可藏的。
  • 订阅一个模块没有声明过的类型是契约错误,以一个类型化的 Problem 浮现出来——绝不是悄无声息的空操作。
  • 在发出这一刻,有三种校验拒绝:一个未声明的类型、一个通不过 schema 的载荷,以及一个该类型不允许的目标。
  • 对一个 retained 类型超出期限的选择会被拒绝,而不是答以一页空的结果。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 载荷大小——超过上限,这次发布失败,这个 Event 不会发生,绝不会是一个被截断的载荷。
  • 每个来源的发布速率——一次限流拒绝,带上可以重试的时刻。
  • 每个 Actor 的订阅数——新的那个被拒绝,已有的那些保留。
  • 每个类型的保留量——按已声明的策略、按期限逐出,绝不随机。

用户流程

一次集结呼叫,从 Declaration 一直到每个小队成员在自己屏幕上看到的那个标记。

One rally call from the declaration to the marker on each screen: the audience is the declared target narrowed to whoever subscribed, and the sender never enumerates it
Building blocks

RPC

一次类型化的调用,而它的方法体住在别处。 RPC 是第二个 Primitive:把这个过程声明在它该在的地方——一个模块上,或者一个 Entity 里面——然后每一种绑定都会得到一个生成的、可 await 的方法。动词是 invoke:单向是 Declaration 点名的一种模式,不是第二个动词,而且并不存在 do。

Who addresses RPC, the 6 things it provides, and the 1 module it builds on

何时使用

  • 调用方需要一个答案——请求/响应,带类型化的返回值。
  • 调用方上报完就走人——一个已声明的单向 RPC,什么都不往回传。
  • 这件工作比这次调用活得更久——一个已声明的延迟 RPC 交还一个工作描述符,而不是一次超时。
  • 一个问题,多个回答者——一次 Group 调用就是 N 次调用,而每个答案到达时都绑着发出它的那个成员。
  • 这个动词属于某样东西——就把它声明在那个 Entity 里面;一个 Entity 的 RPC 别处不存在(Entity 展示了这份 Declaration)。
  • 不必用它:没有人被要求去做什么的时候——一个别人只是对之作出反应的事实,是一个 Events。

谁做什么

Actor在本页
schema-author声明 RPCs、它们的模式,以及谁可以调用它们
any actor在 Declaration 允许的地方,发起一次有回复的或单向的调用
group member回答一次扇出调用;每个成员往回传一个答案

一览

Declare an answering and a one-way RPC; invoke both, then fan out to a group
[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)
Coming soon — Go

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);

方法体在哪里执行——云函数、客户端、master-client 还是游戏服务器——属于路由,按方法声明;覆盖和中间件由 Extensibility 负责。一次失败的调用抛出一个类型化的 Problem(Core)。

模型

一个 RPC 声明什么。

声明是什么
name取自那套动词词汇
input调用方必须选定的那些参数
output恰好一个已声明的类型。一份更短的回复是它自己的一个已声明类型,绝不是同一个类型悄悄少了几个字段——否则“没有请求”“这个对象不在”和“被访问掩码藏起来了”就会变成同一种无从分辨的缺席
reply modewith a reply——一个已声明输出类型的值,或者一次类型化的拒绝;或者 one-way——没有回复,调用方只会知道本地的发送失败。当调用方需要那个结果时,绝不能用单向:一个未知的结果比一次已知的拒绝代价更大
execution modeimmediate——结果在这次调用之内返回;或者 deferred——这次调用返回一个工作描述符,而结果靠读取或者靠订阅到达。这是声明出来的,绝不由实现根据负载来挑,因为调用方是按回复的形状来搭它的行为的
streaming输入和输出是否分批到达,并在到达时逐批处理,而不是整体处理
idempotency一个单向 RPC 同样携带一个幂等键:没有回复不等于没有重复投递
overridability声明在方法本身上。没有声明就意味着不可覆盖——绝不会默认可覆盖
context它被声明在哪里。一个声明在 Entity 里面的 RPC 是那个 Entity 的一部分,在它之外不存在。在游戏服务器里声明一个就是把它注册进路由器——不存在第二种添加方式

一次调用携带什么。

携带是什么
arguments只有调用方必须选定的那些
implicit context接收方、调用方和环境上下文,在你写的第一个参数之前就已绑定——一个 Entity 的方法绝不会被要求提供那个 Entity 的标识符
references一个本身是 SDK 对象的参数,以一个类型化的 Ref 传输——一个标识符或者一个游标,绝不是它内容的副本。接收方以它自己的名义去解析它,受同样的权限和谓词约束:一个引用是一个地址,不是一次被授予的权限
outcome一个已声明输出类型的值,或者一个类型化的 Problem

一次延迟调用的描述符持有什么。

持有是什么
stateaccepted → running → completed 或 failed,后两者是终态
lifetime声明出来的;过了它,结果就不再可得,去要它是一次拒绝,而不是一个空答案
cancel幂等,而且诚实:它只是提出请求,而你观察到的那个终态,是这件工作实际抵达的 completed 或 failed 中的那一个

每一个 RPC 都成立的事。

总是成立是什么
one handler恰好一个逻辑处理器——这正是把一个 RPC 和一个 Event 区分开来的地方,后者可以一个都没有。所以对一个 Group 寻址是 N 次调用而不是一次:Groups 提供地址,而答案作为一条流回来,每个都绑着发出它的那个成员
meaning一次执行某个动作的请求,而一个 Event 是对一个事实的断言。一个单向 RPC 和一个 Event 从外面看很像,但不是一回事:一个 RPC 的处理器有义务存在,而一个 Event 完全可以没有任何接收者,那很正常
no state machine一份 Declaration 没有状态机,一次立即调用也没有——它要么返回了一个结果,要么没有,然后超时规则适用。只有延迟调用才有可观察的状态
a stream不是原子的:一个流式输出对整体不作任何承诺:接收方必须准备好应对中断,并且能把“这条流完成了”和“这条流被中断了”分辨开
no predicate on a write没有任何写入接受一个谓词作为输入:“对所有符合这个条件的人做这件事”不是一个 operation。批量动作靠枚举来表达——读出那个集合,把清单交给一个带已声明部分失败语义的批量 operation。作为写入的输入,谓词是在一个没人点名的时刻、对一个没人看见过的集合求值的

每一个 RPC 都经由路由器抵达它的处理器,而它的哪一个方向来作答,是按方法声明的,而不是调用点的一项属性——参见 Extensibility。

An immediate call returning its result against a deferred one returning a work descriptor, with the outcome arriving later as its own delivery
A deferred RPC: the call returns a work descriptor, the outcome arrives against it
[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 ran
export 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 ran
Coming soon — Go

Attribute-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 ran

错误

  • 权利在 RPC 上,绝不在 Primitive 上。 不存在一个笼统的“可以调用”:每一份 Declaration 都点名它的调用方必须持有的那个 atom,而一个不持有它的调用方会得到一次类型化的 forbidden 拒绝,代码一应俱全——不是悄无声息地丢掉。
  • 一个被藏起来的实例读起来是“not found”。 一次在调用方行谓词所藏起来的实例上发起的 Entity RPC,作答的方式跟读取那个实例完全一样,所以这次拒绝不会告诉调用方任何关于“什么存在”的信息。
  • 没有处理器是“不可用”,不是“not found”。 这个 RPC 是声明过的,所以它存在;缺的是一条路由。那种拒绝是可重复的——一台游戏服务器可以回来——而“not found”会告诉调用方别再试了。
  • 一次 Hook 拒绝携带 Hook 自己的代码和原因,所以“被一条游戏规则拒了”绝不会看起来像“传输坏了”。
  • 超时不是一个结果。 对于一次延迟调用,你去读那个描述符;对于一次立即调用,让重试变安全的是那个已声明的幂等键——单向调用也包括在内,在那里,没有答案并不意味着没有重复投递。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 输入大小——这次调用在执行之前就被拒绝。
  • 输出大小——被拒绝而不是被截断,因为一个被裁短的答案和一个完整的答案无从分辨。
  • 每个 Actor 的调用速率——一次限流拒绝,点名何时重试。
  • 每个 Actor 的并发延迟调用数——新的那个被拒绝,在途的那些跑完。
  • 描述符寿命——过了它,结果就不再可得,而那是一次拒绝。
  • 调用链深度——超出时是一次已声明的拒绝,绝不会是资源耗尽或者悄无声息地断掉。

用户流程

一次分数提交、一次延迟上报,以及一次询问某个小队准备好了没有。

Building blocks

Data & Subscriptions

你改一个字段。下游的一切都在不写一行代码的情况下发生。 Data 是第三个 Primitive:垫在每个同步字段底下的那套机制——相对上一次已确认状态的 Deltas、作为策略单位的切面、优先级和发送速率、可续接的订阅、保留窗口,以及变更前/变更后的 Hooks。

你面向的是 Entities,不是表——读取和修改的接口面(查找、过滤、排序、分页、订阅一个选择集)参见 Entity;本页讲的是底下的机制。不存在通往一张表的消费方路径,也不存在第二种写入方式:一次变更是一个 Entity operation,而 Delta 是随之而来的东西。

Who addresses Data, the 5 things it provides, and the 1 module it builds on

何时使用

  • 你需要把状态复制到客户端,却不想写快照代码——改一个字段就是全部的同步。
  • 各个字段在紧急程度或受众上不同——按切面设优先级和发送速率上限,再加一个用于战争迷雾的可见性谓词。
  • 一个重连的客户端绝不能悄无声息地跑偏——空档会被检测到并被点名,而超出保留窗口的空档会以完整状态作答。
  • 你需要最近的过去——那个按 sim_time 索引的 Deltas 保留窗口,正是预测和延迟补偿所读取的东西。
  • 一条校验规则该待在一个地方——一个变更前 Hook 在变更落地之前把它夹住或否决掉。
  • 不必碰这些旋钮:你只需要读取或查询时——Entity 的那套接口面骑在这些机制之上,却不必碰它们。

谁做什么

Actor在本页
schema-author声明切面、它们的同步策略,以及可见性谓词
any actor订阅一个目标;从一个位置续接;请求完整状态
backend-service变更前/变更后 Hooks
operator读取按 Actor 计的分包开销;看到投递何时降级、或者一个包何时被截断

一览

Two aspects on tank: motion at 30 sends a second, loadout only for its owner
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence
export class Motion {
  position!: Vector3;
  @Sync({ hz: 4 }) fuel = 0;                             // one field overrides the aspect
}

export class Loadout { ammo = 0; }

@Entity('tank')
export class Tank {
  @Aspect('motion', { priority: 10, hz: 30 })            // policy lives on the aspect
  motion = new Motion();

  @Aspect('loadout', { visible: 'owner == caller.player' })
  loadout = new Loadout();

  internalHeat = 0;                                      // in no aspect — never leaves the server
}

tank.motion.position = next;   // ← the change; the delta is its consequence
class Motion:
    position: Vector3
    fuel: float = sync(hz=4)                             # one field overrides the aspect

class Loadout:
    ammo: int = 0

@entity("tank")
class Tank:
    motion: Motion = aspect("motion", priority=10, hz=30)          # policy lives on the aspect
    loadout: Loadout = aspect("loadout", visible="owner == caller.player")
    internal_heat: float = 0.0                           # in no aspect — never leaves the server

tank.motion.position = next_pos   # ← the change; the delta is its consequence
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FMotion
{
    GENERATED_BODY()

    UPROPERTY() FVector3f Position;
    UPROPERTY(PSSync = (Hz = 4)) float Fuel;             // one field overrides the aspect
};

USTRUCT()
struct FLoadout
{
    GENERATED_BODY()

    UPROPERTY() int32 Ammo;
};

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "motion", Priority = 10, Hz = 30))   // policy lives on the aspect
    FMotion Motion;

    UPROPERTY(PSAspect = (Name = "loadout", Visible = "owner == caller.player"))
    FLoadout Loadout;

    float InternalHeat = 0.f;                            // no UPROPERTY, in no aspect — never leaves the server
};

Tank->Motion.Position = Next;   // ← the change; the delta is its consequence
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline) — declarations push into the same model, and changing a field is the same whole sync
public class Motion
{
    public Vector3 Position;
    [Sync(Hz = 4)] public float Fuel;                    // one field overrides the aspect
}

public class Loadout { public int Ammo; }

[Entity("tank")]
public class Tank
{
    [Aspect("motion", Priority = 10, Hz = 30)]           // policy lives on the aspect
    public Motion Motion = new();

    [Aspect("loadout", Visible = "owner == caller.player")]
    public Loadout Loadout = new();

    public float InternalHeat;                           // in no aspect — never leaves the server
}

tank.Motion.Position = next;   // ← the change; the delta is its consequence

模型

一个 Delta 携带什么。

字段是什么
changed fields只有那些,绝不是整个对象
pair它所属的那个实例 × 切面
number在那个二元组内部的一个序号,正是它让空档变得可检测

一个切面声明什么。

全都通过特性来写,写在切面上或者某一个字段上,绝不通过运行时的一次调用。切面设定默认值,而一个字段可以覆盖它;切面仍然是策略的单位,因为否则就没有东西可以拿来装配 presets 了。

声明取值,以及它不是什么
priority在通道不够用时,决定先发什么。它不是关于延迟的承诺:它是相对的,它排的是字段之间的发送顺序,而不是保证一个投递期限
max update rate对发送的一个上界。它不是关于以那个速率接收的承诺——接收取决于通道
delta only没变的东西就不要发
delivery modeshared packet——给所有人同样的东西,省 CPU;或者 per-actor packet——按各自的可见区域各发各的,费 CPU,而且在人数大时是必需的
visibility rule决定到底谁会收到的那个谓词——Visibility 把那一半完整地投影出来
A change sent to each receiver as the difference against what THAT receiver acknowledged, and the full state instead once it falls out of the retained window

一个订阅持有什么。

持有是什么
target一个实例、一个选择集,或者一个切面,而它接收那个目标的 Deltas。一个目标不是一条流:一个目标可能覆盖许多二元组,而顺序是在一个二元组内部承诺的,不是跨整个目标
position它从哪里续接:由消费方出示。如果这个空档大于保留窗口,到来的就是完整状态,而不是一串 Deltas,所以一次长时间断连绝不会让客户端悄无声息地错着
stateactive → gap detected → resynchronised | closed,而 closed 是终态

每一条流都成立的事。

总是成立是什么
mergingDeltas 允许合并:两次发送之间的 100 → 90 → 80 可能作为 100 → 80 到达,因为最终状态仍然是对的。这恰恰是把一个 Delta 和一个 Events 区分开来的地方,后者丢一个就永久丢掉了信息
gap detection悄无声息地丢掉一个 Delta 是被禁止的;二元组里的那个序号就是消费方用来数的东西
ordering在同一个实例 × 切面二元组内部成立;二元组之间不承诺任何形式的顺序
traversal只针对被声明过的东西:可以作为过滤器、排序或包含项的,是一个已声明的字段和一个已声明的引用。Entity 的选择接口面是那个模型的投影,而这个 Primitive 不给消费方任何它自己的遍历方式——不存在第二种查询语言
history是由 Deltas 构建出来的:一个 Entity 的瞬时窗口,是一个按 sim_time 索引的 Deltas 保留窗口。它的深度是这个 Primitive 的一条限制,而它对浮点字段不承诺可重现性
the packet budget按声明降级:当按 Actor 计的预算用完时,平台按声明回退到共享包,而不是开始随意丢掉接收者

一个 Hook 可以做什么,以及在什么时候。

A before-change hook on the motion aspect: negative fuel is rejected before the change lands
[Before(Data.Change, aspect: "tank.motion")]
public static Verdict ClampFuel(Change<Motion> change) =>
    change.Next.Fuel < 0 ? Hook.Reject("negative fuel") : Hook.Continue(change);
export const clampFuel = before(Data.change, { aspect: 'tank.motion' }, (change: Change<Motion>) =>
  change.next.fuel < 0 ? Hook.reject('negative fuel') : Hook.continue(change));
@before(data.change, aspect="tank.motion")
def clamp_fuel(change: Change[Motion]) -> Verdict:
    return hook.reject("negative fuel") if change.next.fuel < 0 else hook.proceed(change)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unreal code 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — your Unity code subscribes to the resulting events.

Hook它可以做什么
变更之前修改它,或者否决它。一个被否决的变更根本不产生 Delta——订阅者什么都看不到,而不是先看到一个值再看到一次纠正
变更之后追加副作用,而且它永远无法让这次变更失败

删除挂在 Entity 上,删除住在那里;这个 Primitive 挂的是变更。

错误

  • 一个未声明的订阅目标是一次校验拒绝。
  • 没有订阅权限回答 forbidden 还是 not found,取决于这个目标的存在本身是不是秘密——这次拒绝绝不能变成一个神谕。
  • 一个解析不了的续接位置是一次错误请求,绝不会是悄无声息地从现在重新开始。
  • 一个被平台一侧关闭的订阅是一次冲突,而且它是可观察的:状态机的 closed 是终态,抵达它不是客户端需要去推断的事。
  • 订阅数用尽是一次冲突——权限是有的,位置没有了。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • Delta 保留窗口——从比这个窗口更早的位置续接,得到的是完整状态,而不是一次拒绝。
  • 每个 Actor 的订阅数——新的那个被拒绝,已有的那些继续。
  • Delta 大小——这个 Delta 会被拆开而不是截断,而且这次拆分是可观察的。
  • 发送速率——一个上界,不是一项保证。
  • 一个按 Actor 计的包的开销——用尽时,按声明降级到共享包。

用户流程

一次位置变更,从那次赋值一直到每块屏幕上被纠正过的运动。

One field assignment reaching every screen: the before-hook can still veto it, and a receiver past the retained window is sent the full state instead of a stream of deltas
Building blocks

Groups

一份名单,一个群体监听者。 一个 Group 是第四个 Primitive:一组被命名的 Actors,作为一个整体来接收。你面向这个 Group 说话,每个成员都听得到——一个 Room、一个聊天、一个 matchmaking 池和一份发送名单,是同一个 Primitive 在不同规则下的样子:不同的进出逻辑、不同的生命周期,底下是同一份名单。

Who addresses Groups, the 4 things it provides, and the 1 module it builds on

何时使用

  • 你需要队伍、小队或者公会——一组被命名的玩家,带一个已声明的容量,以及在类型声明了的地方,一个生命周期。
  • 成员关系应该跟着一条由平台求值的已声明规则走——新晋老兵自动落进来,不需要一个定时任务,也不需要你自己去调一次重新求值。
  • 你想一次对许多玩家说话:一个已声明的 Event 用 send.* 扇出去,一个已声明的 RPC 到达每个成员,而每个答案回来时都带着名字。
  • 你需要把一个成员关系模型复用成一份受众——一个 Visibility 作用域、一场 Messaging 会话、一支 Matchmaking 队伍。
  • 不必创建一个,当这组人就是一场会话的成员时——Rooms 就是这个 Primitive 配上 Room 规则,而且已经把那些人寻址好了。

谁做什么

Actor在本页
player从已声明的类型创建 Groups,加入和离开,添加或移除成员,发送 Events,发起扇出 RPCs;在持有某个 Group 成员关系的管理权时,移除成员并关闭它
room-ownerRoom 的座位规则骑在这个 Primitive 之上(在 Rooms 里配置)
backend-service声明 Group 类型和它们的规则;在进入和退出时挂 Hooks

一览

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

一个 Room 的聊天就是这个 Primitive 加上一层消息语义:这个 Room 声明它自己的 Group 类型,把它挂在 Room 的命名空间里,然后让 Room 自己的成员关系去决定谁在里面——这样聊天的名单和 Room 的名单就永远不可能对不上。

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

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

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

关于聊天的一切都不在这个 Primitive 里面。受众和 send.* 接口面来自这里;作者、话题串和历史来自 Messaging,而谁可以管理这份成员关系是一项它自己的权利(Access & Roles),由这个 Room 持有。

模型

一个 Group 类型声明什么。

声明是什么
name这个类型自己的名字
membership modeexplicit——一个成员靠一次动作被添加和移除;或者 dynamic——成员关系是从一条规则推导出来的,谁满足那个谓词谁就是成员。一个 Group 只能是两者之一,绝不能兼具
rule对一个动态 Group 而言:那个谓词,用的是跟访问谓词和转移守卫同一套谓词语言。是平台在重新计算它;没有人去轮询
capacity以及达到它时的行为
entry rule一个可以拒绝进入的谓词,与一个同样可以拒绝进入的 Hook 相互独立
lifecycle behaviour在首次进入时——created on first entry 或 created in advance;以及在最后一次退出时——closed on last exit 或 kept while empty。这是声明出来的,绝不从观察中推断
lifetime可选:一旦它到期,这个 Group 带着一个 Event 关闭

每一个 Group 都成立的事。

总是成立是什么
member是一个 Actor,绝不是一个 Entity:一组 Entities 是 Data & Subscriptions 上的一个选择集。一个 Group 是一个群体监听者
statescreated → active → closed,而 closed 是终态。一个 Group 实例有一个状态机;类型不声明它
event target在它上面发出,它的成员就会收到;正是这一点让批量投递成为一个信号,而不是一个循环
a group call是 N 次调用,不是一次:这个 Group 提供寻址,而每个结果到达时都绑着它来自的那个成员。RPC 要求恰好一个逻辑处理器,所以一次等待许多答案的广播是 N 次调用,不是一次
partial outcome绝不会读起来像一个完整的结果:一个失败了、超时了或者拒绝了的成员,就是它自己的一个答案,带着它的 Problem,跟那些答上来的并列;一次部分成功绝不会作为完全成功返回
recipients绝不由发送方枚举:由成员关系决定,所以发送方不必知道这份受众的构成
intra-group roles不存在:一个“Group 所有者”是一个持有某项权利的 Actor(Access & Roles),不是存在成员名单里的一个级别
first entry and last exit跟中间那些加入和离开是可分辨的——已声明的生命周期行为和回合初始化就挂在这上面
recomputation带的是差异:一个规则声明的 Group,它的构成 Event 说的是谁进来了、谁掉出去了,绝不是整份名单。整份名单是一次读取,所以一个只想要变化的订阅者永远不必为那份花名册付账
join and leave是幂等的:一个重连的客户端重复它的加入,得到同一份成员关系,没有错误——客户端代码永远不必去分辨“我已经在里面了”和“我不被允许进去”
the interface是某个具体 Group 的,不只是类型的:你面向的是这支小队
the primitive保持空的:进入规则、在第一个成员到来时做回合初始化、Event 拦截——那些是建在它之上的模块。一个 Rooms 是一个配了座位规则的 Group,一场 Messaging 会话是一个配了投递规则的 Group,一个 Matchmaking 池是一个被撮合器抽干的 Group,而一份发送名单是一个完全没有规则的 Group

错误

  • 一条规则说不行和一个 Hook 说不行,是两个不同的答案。 一个为假的进入规则读起来是“无法进入”;一次 Hook 拒绝带着 Hook 自己的原因和代码。一个够不着的入场 Hook 会拒绝这次进入——这项检查失败时是关闭的,而不是把这个 Actor 放过去。
  • 动态成员关系拒绝手工编辑。 在一个规则声明的 Group 上调用 Add 或 Remove 是一次校验拒绝:那个谓词是唯一能挪动这份名单的东西,而当它背后的数据变化时,平台会重新计算它。
  • 四项权利,谁也不蕴含谁——进入、管理成员关系、向这个 Group 发布、读取构成(Access & Roles)。一个缺了其中之一的 Actor 会得到一次拒绝,绝不是悄无声息的空操作;而一个被可见性谓词对它藏起来的 Group 则回答“not found”,并且一个成员即便看不到构成,也总是看得到它自己的成员身份。

限制

  • 满员是一次冲突,不是权利问题。 在容量 + 1 时,这次进入作为冲突被拒绝——这个 Actor 是被允许的,那个位置不是——而一旦有位置空出来,同一次调用就会成功。容量本身在类型上(上面的 Capacity = 4);一个 Project 和一个单独的 Actor 各能持有多少个 Groups,随平台限制那一章一起设定。
  • 一次超大的 Group 调用会被整体拒绝,在任何东西发出去之前——一次扇出绝不会只投递一半,所以没有哪个调用方需要去检测那种情况。规模上限随平台限制那一章一起落地。

用户流程

一支队伍组建起来,一次集结呼叫到达每个成员,一次扇出 RPC 按成员各带回一个答案,然后这支小队作为一个整体去排队。

A party from creation to the queue: one emit reaches every member, one fan-out call brings back an answer bound to each, and the party enters matchmaking whole
Building blocks

Extensibility

平台的每个场景都是一串已注册的函数。替换其中一环,或者把它包起来。 这就是“可定制的平台”具体意味着什么,也是它用来代替开源的东西:你用你自己的步骤替换掉平台自己的步骤,所以你不需要我们的源码。

Who addresses Extensibility, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 某个平台步骤必须跑你的逻辑——用 [Override(…)] 为一个被命名的环节声明替代实现。
  • 你需要在一个步骤前后做检查或者产生副作用——有顺序的 Before/After 中间件,可以否决或者通知。
  • 代码必须按排程、按 Event 或者按 webhook 运行——触发器递给你一个已解析、类型化的上下文。
  • 你必须在部署之前就知道实际会跑什么——对一条链做一次演练,读出解析后的顺序。
  • 不必用它:这条规则只关乎一个 Entity 的写入时——一个 Data & Subscriptions Hook 是更轻的形式。

谁做什么

Actor在本页
backend-service覆盖环节,用中间件把步骤包起来,写触发器处理函数
operator检视链条,设定顺序,读取 secrets,对解析结果做演练

一览

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.

模型

平台给你什么东西去挂靠。

术语是什么
registered function一个可覆盖的平台步骤——“创建档案”“解析价格”
scenario一个平台流程所跑的那条有序的链:认证、加入、购买
overridability一个环节是可替换、只可包裹,还是固定不动
middleware环绕一个环节的、有顺序的前置/后置处理器
trigger启动你代码的东西:一个 Event、一个排程、一个 webhook
secret你的处理函数可以读取的一个值
invocation一次运行,连同它的追踪
A scenario as a chain of registered steps with one link replaced by your function, the platform step still behind it as the fallback

一个 Hook 声明什么。

声明是什么
position它所挂靠的那个被命名的步骤
kindgatekeeper——一次准入检查或者一次校验,而它失败时关闭,所以当这个 Hook 自己坏掉时,那个步骤不会运行;或者 observer——一条日志、一次通知、一个计数器,而它失败时放行,步骤照跑,而这次失败仍然会被上报而不是被吞掉。没有默认值
momentbefore——在校验之前,接收类型化的载荷,可以修改或者拒绝;或者 after——在这个步骤提交之后,接收请求和结果,只能产生副作用,而且永远无法让这次 operation 失败或者改动响应
effect这个 Hook 做什么,而不只是它坐在哪里。正是这一点把“这些里面哪个先跑”从一场关于数字的争夺,变成一句关于工作内容的陈述,也正是它让顺序能在别人往你旁边加一个 Hook 之后仍然成立
version and condition一个只适用于某一个 Environment 或某一群受众的处理函数,是那个 Hook 的一个已声明版本,而不是它方法体里面的一个分支,而这也正是面板里那个开关所切换的东西

三种挂代码的方式。

形式用它来做
[Before(Step)] / [After(Step)] 特性在一个被命名的步骤上加一条规则——大多数 Hooks
[Override(Link)] 特性彻底替换掉一个环节的实现
Extend.Scenario("…").Before("link", fn, order: n)在一条链里面包裹一个环节,当相对其他中间件的顺序要紧时
总是成立是什么
all three都用 playserv push 部署
the two attribute shapes是面板会渲染的那两种,因为这份 Declaration 把步骤名或环节名一起带进了被推上去的模型
the middleware form带的是一个顺序值,而那正是一条链所需要的
assigning at startup(Scenario.OnX = fn)对于一个不需要出现在管理树里的处理函数,仍然可用
replacing one link让两侧的环节原封不动,而它们谁也不知道是哪一个实现作的答——平台自己的步骤,还是你的

一次调用去哪里,以及每条路由都成立的事。

总是成立是什么
four directions一个云函数、消费方的外部后端、游戏服务器、另一个已声明的方向
the router是消息/信号驱动的;request-response 是搭在它上面的一种适配器,而不是它的本性
matching只按 operation 或信号的已声明名称来匹配,别的一概不看:不看载荷形状,不看调用方,不看负载
a name registered twice是这份 Declaration 的一个缺陷,在这组东西被声明时就被拒绝,而不是留到调用时去解
an unregistered name回答 not found,而不是被悄无声息地丢掉
the direction不是这个 operation 契约的一部分:把一个处理函数在方向之间挪动不是一次破坏性变更
"the game server"是由它是什么来定义的,不是由谁托管它来定义的——我们的机队和一家工作室自己的托管是同一个方向,而这份 Declaration 不带任何关于基础设施归谁的标记
game-server RPCs注册进同一个路由器:声明一个就是注册它,而且不存在第二种方式
ordering中间件自上而下跑,而当一个步骤有不止一个实现时,路由器按条件从左往右挑,由标为默认的那个版本在什么都没匹配上时作答

Hooks 之间的约束是什么,以及它什么时候被检查。

是什么
a named constraint一个扩展点可以点名它所约束的那些效果——一次反作弊检查必须先于一次放置、一张收据在这一点上要求有一次扣款——而不约束别的任何东西
an unnamed effect是不受约束的,而不是被拒绝:一个消费方去做没人预见过的事,正是这套机制的用途所在,而一份封闭的词汇表会把那件事变成一次注册时的拒绝
a violation是配置的一个缺陷,而这次拒绝会同时点名两个 Hooks 和它们违反的那条约束——不是一条警告,也不是一次悄无声息的重排
when it is checked在每一次可能改变某个点上会跑什么的行为时:注册、部署、改动这个编排。所以一个走到执行的编排,是已经被准入过的
never re-checked at run time那会是对一个已经定下来的问题的第二次回答,而且问在唯一一个已经无能为力的时刻
总是成立是什么
handlers输入输出都是类型化的:没有 dynamic,没有塞满杂物的 context 对象。平台句柄是环境性的,而调用上下文——调用方、触发器、追踪——到达时已经解析好了
what an engine build sees是这个场景之后发出的那些 Events,因为一次覆盖或一个中间件跑在平台上,而引擎运行时不是托管它们的地方。这正是本页示例上那些 @na 页签所说的“订阅由此产生的那些 Events”的意思
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.

拿走它,改掉它,发出去。“专有的开源”是一套工作流,不是一句口号——平台自己的逻辑就是你可以拉下来、编辑、再部署的函数:

playserv functions pull matchmaking.match     # 平台的实现,以源码形式
# 编辑:为周末活动放宽技能窗口
playserv push                                 # 你的版本注册上去;默认那个仍然作为兜底

这里的定制只有一条轴,而它是逻辑:本页所讲的那些覆盖、中间件和版本。

另一条轴并不存在。 你没法把自己的字段加到一个平台 Entity 上。一名玩家、一个 Room、一条 Leaderboard 记录和一份订单都是带着各自状态机的系统状态,而不是你数据模型的开端。你的数据是你自己的 Entity,在 Schema as Code 里声明,并通过一个谓词绑到平台的状态上——所有者是这名玩家,范围是这个 Room——而这也正是当平台自己的模型挪动时,它们仍然属于你的原因。

错误

  • 一个匹配不上任何已注册处理函数的名字回答 not found——一次调用绝不会因为没人在听就被悄悄丢掉。
  • 一个被注册了两次的名字,以及一组里有两个实现声称同一个条件,都在这组东西被声明时被拒绝——在部署时,而不是在调用时靠掷硬币解决。
  • 一次 Hook 拒绝携带 Hook 自己的代码和原因,所以“一条游戏规则说不行”绝不会看起来像一次传输失败。
  • 一个失败的 Hook 按它已声明的种类行事——fail-open 或 fail-closed——而它是哪一种是声明出来的,不是从发生了什么推断出来的。
  • 一个 Hook 不可以改动已经被认定的东西:不能改所有者,不能改目标,也不能改这次调用所寄往的那块榜或那场会话。它纠正输入,然后返回一个裁定。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 一个 Hook 的执行期限——过了它,就按它的种类所声明的失败行为来。
  • 一个位置上的 Hooks 数量,以及一个方法的实现数量——再注册一个会被拒绝。
  • “一个 Hook 调用了一个自身带 Hooks 的 operation”的嵌套深度——一次已声明的拒绝,绝不会是资源耗尽。
  • 传给一个 Hook 的上下文大小——截断是被禁止的,所以被拒绝的是注册,而不是让一个处理函数收到半个上下文。

用户流程

一次购买,从玩家的点击,穿过那条被定制过的链,一直到小队里的一声提示。fraud-check 是这家工作室自己挂在 Before("grant") 上的处理函数,不是一个平台模块;Catalog & Commerce 从它自己那一侧画出同一次购买。

One purchase through a customised chain: the studio’s own fraud-check runs as a gatekeeper before the grant, and the links either side of it never learn which implementation answered

一条带覆盖和中间件的链,可以在任何东西运行之前就被解析出来并读一遍。 解析后的顺序在面板里和从代码里都可以检视。

课程与实战:Tanks 里的 Leaderboard 用到了本模块的 Hooks;每日锦标赛从头到尾贯穿本模块。

Your game's model

Schema as Code

在代码里声明这个模型,把它推上去,拿回类型。 这是开发者进入 schema 的方式:管理面板和代码写的是同一个模型,而 codegen 为每个引擎把这个环闭上。

Who addresses Schema, the 4 things it provides, and the 2 modules it builds on

何时使用

  • 你的数据模型应该住在代码里,并且像代码一样被评审——声明、schema diff、schema push。
  • 引擎类型绝不能和已部署的模型跑偏——schema codegen 重新生成 Unreal C++ 和 Unity C#。
  • 一次破坏性变更必须在它运行之前就可读、可取消——propose → plan → apply。
  • 你在多个 Projects 之间复用同一个包(Stat、Interactable)——把它作为一个 preset 声明一次。
  • 不必用它:一位 operator 只是在管理面板里微调数值时——那次模型变更仍然会 diff 回代码里。

谁做什么

Actor在本页
schema-author在代码里声明 entities/parts/enums,做 diff 和 push
operator审阅面板上的总览,提出并应用迁移
ci构建流水线,在一个 backend-service 密钥下运行:合并时推送,随后重新生成引擎类型

一览

Declaring Item with an embedded Stats part and an enum, pushed as one schema
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
@Entity('item')
export class Item {
  name = '';
  rarity!: Rarity;                   // an enum declared the same way
  stats = new Stats();               // a part — embedded, no lifecycle of its own
}

@Part('stats')
export class Stats { power = 0; weight = 0; }
@entity("item")
class Item:
    name: str = ""
    rarity: Rarity                     # an enum declared the same way
    stats: Stats = Stats()             # a part — embedded, no lifecycle of its own

@part("stats")
class Stats:
    power: int = 0
    weight: int = 0
Coming soon — Go

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

USTRUCT(PSPart = "stats")
struct FItemStats
{
    GENERATED_BODY()

    UPROPERTY() int32 Power;
    UPROPERTY() int32 Weight;
};

UCLASS(PSEntity = "item")
class UItem : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString    Name;
    UPROPERTY() EPSRarity  Rarity;       // an enum declared the same way
    UPROPERTY() FItemStats Stats;        // a part — embedded, no lifecycle of its own
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("item")]
public class Item
{
    public string Name = "";
    public Rarity Rarity;              // an enum declared the same way
    public Stats Stats = new();        // a part — embedded, no lifecycle of its own
}

[Part("stats")]
public class Stats { public int Power; public int Weight; }
playserv schema diff       # 本地声明 vs 已部署的 schema
playserv schema push       # 带一个 revision 前置条件——不做盲目覆盖
playserv schema codegen    # 重新生成 Unreal C++ / Unity C# 类型

Push 是一个显式的步骤。 你保存一个文件时什么都不会上传:一份 Declaration 只有在 playserv push(或 schema push)运行时才会抵达已部署的模型,不管是从你的机器还是从 CI,而且它会带上它做 diff 时所对照的那个 revision。面板上的一次编辑,对你来说和其他任何一种偏移一样可见——schema diff 会把它对着你的声明显示出来。看见它是自动的;把它往任一方向搬动,是一条你有意去跑的命令。

模型

一份 Declaration 携带什么。

携带是什么
key它被寻址时所用的那个稳定名字。在代码里改名就是一次改名,不是删掉再建
kind一个 entity、一个 part 或者一个 enum,在代码里声明
ownership modeseed——代码在记录不存在时创建它,而重复推送不会动那些值,所以之后归管理控制台所有;或者 managed——代码始终拥有它,每次推送都把值拉回到声明的样子,而来自管理控制台的编辑会被拒绝,而不是应用之后在下一次推送时丢掉
preset可选:一个可复用的包——一个 Entity preset,比如 stats 或 world-objects——声明一次,然后像类型一样应用。它不引入任何新种类的 Declaration,而一个需要新种类的 preset,说明的是契约里的一个缺口,而不是一个更大的 preset

一次推送承诺什么。

总是成立是什么
matching按键匹配,绝不按符号:给符号改完名之后再推一次,留下的是一条记录而不是两条
idempotency由此而来——重复推送不会变成第二条记录
the report在应用之前就说清楚到底会改什么,在之后说清楚它覆盖了什么
origin是可分辨的:一条由代码推送创建的记录,跟一条在别处创建的记录能被区分开
the revision跟着它一起走,而一次推送要么整体落地、要么完全不落地

codegen 承诺什么。

总是成立是什么
regeneration在每次推送之后发生,而生成出来的类型从不手工编辑:重新生成然后做 diff,不会产生任何变化
naming跟着 Declaration 走,不管它写在哪里——Item 上的 Rarity 字段在 Unreal 里变成 UPSItem::Rarity,在 Unity 里变成 Item.Rarity
the two directions不会分叉:凡是在代码里声明的都会出现在面板上,而凡是一位 operator 在面板里写的都能干净地对着代码做 diff——正是这一点让 schema diff 成为一个完整的答案,而不是半个

一次迁移承诺什么。

总是成立是什么
when one is required一次会重写既有值的持久化 Declaration 变更;而一次破坏性的持久化变更,没有迁移就无法发布
what it declares一个版本、一份预览、一次有序的应用、失败时的回滚,以及一个可观察的完成结果
coexistence在两个数据版本同时在线期间,读和写都声明它们接受哪些版本——运行时绝不会从字段名去推断兼容性

错误

  • 一个没有那个角色的调用方会得到 forbidden,而已部署的模型分毫未动:一次拒绝绝不会是一次半推。那跟一个过期的 revision 是不同的拒绝,后者是 precondition_failed,意思是这次 diff 是对着一个之后又动过的 schema 算出来的——重新 diff,再推一次。
  • 一个超出已声明界限的值在写入时被拒绝,绝不会被夹住,一个超出已声明长度的字符串同理。夹住会产生一个合法但错误的值,而代价落在支持那一侧而不是调用方:一次拒绝的代价是一个来回。
  • 一段无效的 UTF-8 序列在写入时被拒绝,而不是被修补。
  • 一次没有已声明迁移的破坏性持久化变更根本无法发布。

限制

凡是运行时症状看起来不像一次拒绝的地方,Declaration 形状的上限都是在声明时——在部署或发布时——而不是在首次使用时被检查的。那是平台限制那一章所陈述的规则,也是为什么一个发得出去的 schema,是一个本来就装得下的 schema。数字本身随那一章一起落地。

用户流程

一个新字段,从它在代码里的声明一直到重新生成的引擎类型。

Your game's model

Entity

其余一切所倚靠的那个模块。 一个 Entity 是一份 schema Declaration,长出了各种实时切面:数据 0..*、状态 0..*、RPC 0..*、Events 0..*、Hooks,以及变更历史。 地图把障碍物绑到 Entities 上,碰撞绑一个变换切面,Stats 就是一个 preset,而世界物体是一个 preset 加一个状态机。

Who addresses Entity, the 5 things it provides, and the 3 modules it builds on

Entity 挂载在根上,所以 room.Entity<Door>(id) 和 playserv.Entities<KeyDef>() 直接坐在根上,而不是躲在某个命名空间后面。它建在三个 Primitives 之上——Events、RPC 和 Data & Subscriptions——除此之外别无所依。Collision、Locomotion 和 Prediction & Lag Comp 坐在它上面:每一个都绑到一个切面上,而不是绑到整个 Entity 上,这也正是为什么一次接触可以触发一次状态转移,而碰撞模块对权限一无所知。

何时使用

  • 一个世界物体需要的是行为,而不只是字段——状态机、带权限的 RPCs 和 Events 都在同一份 Declaration 上。
  • 门、陷阱、拾取物:状态转移必须能由客户端 Events、Collision 接触或者 Stat 阈值来触发,而不需要 Room 代码。
  • 你想要一行就能创建出游戏对象——应用或者派生像 world-objects 这样的 presets。
  • 一次争议需要开枪那一刻确切的世界状态——在已声明的窗口之内,按一个过去的 sim_time 读取一个实例。
  • 当那样东西没有身份时就别用它——一个只会活在别的东西里面的值,比如一扇门上牌子的文字,是某个切面里的一个字段,而不是它自己的一个 Entity。凡是被寻址的都是 Entity:Data & Subscriptions 是底下的机制,而且没有任何一条表的路径能绕过它们。

谁做什么

Actor在本页
schema-author声明 Entities、切面、状态机、presets
every actor查询、订阅、调用 Entity RPCs、读取状态

一览

The dungeon door: two aspects with their own policy, a guarded machine, a declared event, and an RPC that names the right it needs
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — the same C# attributes compile in Unity (2021.3 baseline, no newer C# required) — declarations push into the same model
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

切面才是被声明的单位,而不是字段:motion 带着它自己的节奏和它自己的掩码,info 带着不同的另一套,而一个字段恰好属于它们中的一个。正是这一点让一个 preset 可以一次挂上一整组,也正是这一点让 Collision 可以只绑到那个携带变换的切面上,而看不到这个 Entity 上的其他任何东西。

那个块里的状态机由三条规则管着:

  • 一个带点的名字嵌套一层。 open.blocked 与 open 本身相关联,所以声明在 open 上的 close_requested 转移在它里面同样适用,不必重复一遍。当这个状态机停在 open.blocked 时,它就在 open 里——对 open 的状态检查为真,而 OnEntered("open") 在进入时已经触发过,不会为这个子状态再触发一次。
  • 声明在某个状态上的计时器,只在那个状态是当前状态时才走。 离开 opening 会丢掉它的 AfterSeconds,再次进入则会启动一个新的。
  • 一个守卫是一个已声明的谓词,作用在这个 Entity 自己的字段上,用的是和访问行谓词同一套语言。需要代码的逻辑是 Hook,不是守卫。

字段路径就是被推上去那个模型的拼写。 谓词和查询路径按 Declaration 推上去的样子来点名字段——motion.jammed、gate.state、info.name——不管各种绑定在本地怎么拼它们。

谁可以调用这个 RPC。 一个 Entity RPC 点名它所需权利的方式,跟每一个 operation 一样:一个权利 atom(entity × execute)加一个说明它覆盖哪些实例的行谓词(两者都归 Access & Roles 所有)。

在 Declaration 里它的意思
caller in entity.room这扇门所在的那个 Room 里的任何一个 Actor,不管他跑的是哪个 build。邻近程度不属于这件事:你得站多近才能收到这扇门的 delta,是 Visibility 施加在这个 aspect 同步策略上的一条规则——那是带宽,不是权限,而把视野放宽从来不会把权利放宽
Actor调用方的身份,跟 whoami 返回的是同一个对象
caller.InventoryInventory 为那名玩家提供的句柄,只要那个模块被挂载,处处可用

playserv push 才是让一份 Declaration 变真的东西。 Schema as Code 拥有这一步:它把你的声明对着已部署的模型做 diff,带上它做 diff 时所对照的那个 revision,而如果已部署的 schema 动过了,它会拒绝而不是覆盖。一次会破坏已经在线实例的再推送,要走 propose → plan → apply,所以在任何东西改变之前,这份计划是可读的。

在客户端这一侧,这个 Entity 就是 API:

The door as the API: connect, join, call the RPC, take both outcomes — the chime and the locked signal
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
Coming soon — Go

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

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

locked 信号是这扇门自己的 Event:它的目标就是 Declaration 的目标——这个实例——所以订阅了这扇门的每个人都听得到,而发送时不带任何接收者名单。

模型

One schema declaration with data, states, RPC, events, hooks and history around it — a tank and a quest differ only in which of those they carry

一辆坦克、一扇门、一条属性条和一个任务全都是 Entities。它们的区别只在于各自带了哪些切面,别无其他,而正是这一点让其他每个模块都能建在这一个之上。

一个 Entity 声明什么。

声明是什么
aspect一个被整体声明的具名字段组,带它自己的同步策略和访问掩码。一个 Entity 带好几个,而且没有字段同时在两个里面
state machine可嵌套一层的状态、转移和守卫;每个 Entity 可以有好几个
trigger什么触发一次转移——四种来源在下面
entity RPC从这个 Entity 上伸出来的一个动词,声明在视图内部,带上它所需的那个权利 atom
entity event这个 Entity 发出的一个信号,投递给订阅了那个实例的人
hook前置和后置,作用在数据 operations 上和状态转移上,作为云函数部署。Extensibility 声明顺序、裁定的形状,以及一次失败会怎么样
history track这个视图到底保不保留那个瞬时窗口
ref通往另一个 Entity 的链接,持有它的 id 而绝不持有它的 key,所以给一个键改名绝不会弄断一条链接。Include 把它跟着这一页一起拉进来

什么会触发一次转移,而这四种里没有一种是你的代码在一个 Room 里面运行。

来源它怎么触发
client event or RPC任何一个已声明的——上面的 RequestOpen 触发 open_requested
collision一次接触或者一次进入触发体积——陷阱、压力板——通过 Collision 所绑定的那个切面
data threshold声明在一个 Stat 上,0 HP → death,靠 Hook 顺序来执行,而不是靠 Room 里的代码
time一个状态上的 AfterSeconds 是一个已声明的触发器,不是一个协程:它跑在这个 Room 的模拟时钟上,随 sim_time 推进,在这个 Room 不在模拟时停下,而删掉这个实例会连同它的状态机和它们待触发的计时器一起结束

一次选择可以做什么。

轴什么是允许的
filter 和 sort只针对已声明的字段——不存在表句柄,而一次选择是按 Entity 寻址的,作用域限定在一个 Room 或者整个 Project
include一个已声明的 ref,跟着这一页一起拉进来
paging按不透明游标:不是偏移量,不是行 id,而且它的含义撑不过一次版本变更。把它原样传回去,绝不要解析它
access谓词在分页之前生效,所以一页里绝不会因为有被藏起来的行而出现窟窿
live订阅一个选择集会让它保持实时,成员随着它们的数据变化而进出
Query, filter, sort, page by cursor — and subscribe to the selection itself
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
Coming soon — Go

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

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

每一个 Entity 都成立的事。

总是成立是什么
a selection是一组 Entities,不是一个 Groups:一个 Group 的成员是 Actors,它存在是为了让一个信号到达他们全体,而一个选择集是一次碰巧保持实时的读取
a transition request仍然是一个请求:状态机的守卫会跑,行谓词会跑,而一次状态机没有声明的转移会被以 invalid_state_transition 拒绝,而不是被悄悄忽略
pushing past a guard是另一个 operation,带另一个 atom——entity × administer,而任何客户端密钥默认都不持有它
history只有那个瞬时窗口:按 sim_time 索引的近期状态,受一个已声明的深度约束,而窗口之外的一次读取会被拒绝,而不是答以最近的那个值。分支历史——平行时间线、撤销、整场对局的回放——不在范围之内,因为那必须承诺可重现的浮点值,而类型规则并不承诺
the boundary of a change是一个 Entity,而“要么全都发生、要么全都不发生”就停在那里。被同一个调用方改动的两个 Entities——扣一笔钱包、加一件物品——可能被观察到只应用了一半。所以一对必须一起出现的东西不该是两个 Entities:把两个值都放在同一个实例里,边界就把活干了。伸手去抓一个 Hook 来“让它变原子”是没用的,因为 Hook 是环绕一次变更运行的,而不是横跨两次
a declared method with no implementation是一个完成态,而不是一个配了一半的状态。调用它会答以一个裁定,带着机器可读的理由“没有实现”——不是一次拒绝,也不是一次揣着空结果的成功。一次拒绝会意味着这次调用本不该发出;而在这里它本该发出,唯一没有发生的是那个决定
a name never declared跟一个声明了却没有实现的名字,是不同的结果:前者是一次校验拒绝,后者是一个裁定,而代码可以把它们分辨开
an unimplemented call不会人间蒸发——有人调用过它这件事,对工作室是可观察的。这项观察采取什么形式被有意排除在契约之外,所以要建在“它是可观察的”这个事实上,而不是建在某一行日志上
creating an instance携带 entity × write atom:一个云函数、一台专用服务器和一个 master-client 默认持有它,而一个普通客户端只有在某个角色授予它时才有——在每一种绑定里都是如此,不只是 Unreal

Presets。 一个 preset 是一组被命名的切面、状态机、Hooks 和限制,被应用到一个视图上。它不添加任何新概念——一个 preset 带来的一切你都可以手写声明出来,这也正是为什么一个需要新种类 Declaration 的 preset,说明的是模型里的一个缺口,而不是要把这个 preset 做得更大。交付了五个:stats、abilities、projectiles、drops、world-objects,而 Entity Presets 把每一个都完整地声明出来。一家工作室从它们派生出自己的,在代码里或者在面板里——Crate 就是 world-objects 加 stats:

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

错误

  • 一个不存在的实例,和一个被谓词藏起来的实例,两者都回答 not found——所以一次拒绝绝不会告诉调用方某样东西存在但不归它。
  • 一个未声明的字段(嵌套的也算),以及一个没有值的必填字段,都是点名了字段的校验拒绝。
  • 一次状态机没有声明的转移会被以 invalid_state_transition 拒绝,绝不会被悄悄忽略。把一个状态机推过它的守卫是另一个 operation,带另一个 atom——administer,而任何客户端密钥默认都不持有它。
  • 版本不匹配是一次前置条件失败:重新读取,再作决定。
  • 一个已被占用的键是一次冲突。
  • 一次代表某名玩家、却没有点名那名玩家的写入是一次校验拒绝,而不是一次归属于无名氏的写入。
  • 没有权限回答 forbidden,而且读和写是分开的。
  • 一次瞬时窗口之外的历史读取会被拒绝,而不是答以最近的那个值——“那个 tick 没有数据”和“这大概是那个值”是两个不同的事实。
  • 一个超出大小限制的已存实例是一次冲突,会点名那个越界的字段和实测的大小;这个上限是靠累积抵达的,所以在那次失败的写入之前,接近它的过程就是可观察的。

限制

每一条限制都连同它在边界处会发生什么一起声明出来。数字是按 Project 来的,也按 Project 设定;下面的行为现在就已经定了。

限制在边界处
每个视图的切面数、每个视图的状态机数、一个切面内部的嵌套深度这份 Declaration 在 playserv push 时被拒绝,绝不会被悄悄截断
已存实例的大小这次写入作为冲突被拒绝,点名那个字段和实测的大小;在这次拒绝之前,接近这条限制的过程是可观察的
选择集的页大小这一页被裁到上限,而“还有更多”仍然为真——你绝不会拿到一页看起来像是到头了的短页
瞬时历史窗口窗口之外的一次读取被拒绝,而不是答以最近的那个值
单个实例上的变更速率一次限流拒绝,带上要等多久

用户流程

一扇门,从它的 Declaration 一直到玩家听到的那声铃响。

Your game's model

继承与组合

模块之间相互构建,而其中没有一处是子类化。 没有可以派生的基础模块,也没有可以扩展的层级——模块构成一张图。本页讲的是“继承”在这里诚实地讲究竟意味着什么,以及真正在干活的那六种机制。

继承在这里意味着什么

One list borrowed twice — by the room under entry rules, by the chat under delivery rules — with a decorator narrowing what each sees and neither subclassing the other

这个词涵盖四种不同的机制,而把它们分别叫出名字是值得的。

  • 一个 Entity 的 RPCs 属于这个 Entity。 它们别处不存在——不在某个父类上,也不在某个共享注册表里。如果一个方法属于一扇门,它就在这扇门上。参见 Entity。
  • 一个 preset 是一个被命名的包,不是一个基类。Stats、abilities、projectiles、掉落生成器和世界物体都是 entity 的 presets——是一个 Entity 视图所应用的一组切面,这也正是为什么它们住在同一页上叫 Entity Presets,而不是五个模块。应用一个 preset 是加上切面;它不会把你的类型塞到任何东西下面。
  • 覆盖一个平台步骤,是写在你的替代实现上的一个特性。 你不是去子类化我们的;你是声明你自己的,而版本按条件挑选,平台的默认实现作为兜底。参见 Extensibility。
  • 一个模块通过一个装饰器去借用另一个模块,那个装饰器收窄或者丰富被借的接口,而它背后的实现是可替换的。这个案例的完整演示是一个 Room 里面的聊天,在 Groups 上。

以及它不是什么:不存在模块的类层级,因为树只允许分叉,而真实的功能会横跨这些分叉。Matchmaking 要在 Rooms 里预留座位;掉落要通过 Map 放置物品;一块 Leaderboard 由一个挂在 Room 关闭上的 Hook 来喂。那是一张图,而且是有意为之。

那六种机制

每一种都用一个特性声明在它所组合的那样东西旁边——就是管着这个 SDK 里其他一切的那条同样的声明式规则。

挂载点,就像一个文件系统。 一个模块挂载在根上——把好几个接口组合成一套接口面——或者挂进一个命名空间。第二个模块去认领一个已被占用的挂载点,会在挂载时被拒绝,绝不会拖到第一次调用。机制在深入内部。

词法可见性。 名字的可见性跟着嵌套走:一个全局声明在一个模块里面可见,而一个局部声明绝不会向上泄露。一个模块发出什么是另一个问题,声明在它自己的契约里——一个模块只知道它声明过的、或者被注册到它上面的那些 Events。

封装作为一份契约。 一个模块永远不知道是谁在调用它、为什么调用。它暴露什么、它发出什么,就是它公开故事的全部,而除了调用方的授权之外,关于调用方的任何东西都不会改变它的行为。

靠装饰器和控制反转来复用。 一个模块通过一个装饰器去指向另一个,而不是伸手进去掏它,并且接口背后的实现是可替换的。正是这套机制,让你可以用你自己的模块替换掉我们的某一个,而依赖它的那些模块毫无察觉。

Declarations 会把 API 长出来。 在一个 Group 上声明一个 Event,group.Send.ChatMessage(…) 就会连同它的契约一起出现;声明数据 members,一个类型化的 getter 就会出现。Declaration 就是 codegen 的输入——这也正是为什么你要版本化的是 Declaration,而不是生成出来的代码。

从一个模块伸出来的三条寻址轴。 全部实例、单个实例,以及单个实例的管理员,是三套彼此不同的 API,不是一套带开关的 API。完整的陈述在 Groups。

隐式参数,以及为什么它们不是魔法

在一个 Entity 里面,你从不需要把这个 Entity 传进去。接收方、调用方和环境上下文会自动绑定,因为这三者已经由“这次调用是在哪里发出的”和“是谁发出的”决定了——把它们传进来,等于是要你把平台已经知道的东西再复述一遍,还顺便给了你一次说错的机会。机制在 RPC。

Your game's model

Entity presets

一个 preset 是一组被命名的 Entity 切面——数据、状态、RPC、Events、Hooks——为某一个游戏场景打好了包。你可以应用一个 preset、调它的数字,或者派生你自己的。应用一个 preset 是往你的类型上添加切面;它不会把你的类型塞到任何东西下面——一个 preset 不是一个模块,也没有任何属于它自己的东西可供继承。Stats、abilities、projectiles、掉落表和世界物体是五个 presets,不是五个子系统:同一份 Declaration、同一套同步、同一个 Hook 顺序。

Who addresses Entity Presets, the 5 things it provides, and the 1 module it builds on

何时使用

  • 你游戏里的某样东西带着一些会被夹住、会再生、并且在触到边界时触发一次转移的数字。
  • 一个动作需要消耗、冷却、阶段和效果,而且要能从一个客户端动词就够得着。
  • 有东西飞出去了,而它的命中必须对一名有延迟的射击者公平地判定。
  • 战利品必须来自加权概率,并且在玩家对一次掉落提出争议时能被精确重放。
  • 地图上有家具——门、按钮、陷阱、可破坏物——它们的状态必须挺过一次中途加入。
  • 不必用 presets,当一个 Entity 就是普通的同步数据时。把字段声明出来,到此为止。

谁做什么

Actor在本页
schema-author声明 stats、abilities、projectiles、掉落表、世界物体
room-owner调 preset 的数字,摇掉落表,创建世界物体
player施放 abilities,开枪,捡战利品,跟物体互动

一览

Derive Crate from two shipped presets, then create one per line and tune it to 250 HP
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
Coming soon — Go

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

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host (dedicated server / master-client)
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — the same C# declaration; creating is a room-host surface, and a Unity client sees the crate arrive
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

模型

Five presets as named bundles of aspects over one entity, sharing its declaration, sync and hook order — applied, never inherited

一个 preset 不引入任何新概念。 它添加的一切,用 Entity 已经给出的手段就能表达出来——切面、状态机、Hooks、持久化。一个需要新种类 Declaration 的 preset,说明的是契约里的一个缺口,而不是把这个 preset 做得更大的理由。这就是判断某样东西属不属于这里的全部标准。

每个 preset 给了什么,以及它在哪里调。

Preset契约给了它什么在哪里调
Stats一个带边界、再生和修饰量的数值特性切面,外加一个在触到阈值时的 Hook——0 HP 变成一次状态机转移,而不是你代码里的一个 if那个字段的 Declaration;数字在面板里保持可实时编辑
Abilities一个装着一组 abilities 的切面、一个施放阶段的状态机,以及消耗和冷却那个 ability 的 Declaration
Projectiles一个带 runtime 持久化的类型、一个弹道切面,以及一个命中 Event那个 projectile 的 Declaration——换一个特性就换掉飞行模型
Drops一个带权重的掉落表切面,以及一个死亡之后的 Hook那张表的条目和权重
World Objects一个可交互物体的状态机,以及一个交互条件切面这个 preset 的 Declaration,或者创建时按实例来
Inventory一个带通往某个 catalog 条目 ref 的自有类型、一个带增量的堆叠切面,以及一个带已声明溢出行为的按所有者上限那个类型的 Declaration

这张表每个 preset 只占一行,因为一行就是它们之间的全部差别。它们共有的东西在下面,而 Inventory 是唯一还另有一页的那个。

每个 preset 都成立的事。

总是成立是什么
where it sits坐在一个 Entity 上,以切面的形式:它的数据像其他任何数据一样同步,它的状态就是 Entity 状态,它的 RPCs 就是 Entity RPCs,而它的 Hooks 按 Entity 的 Hook 顺序运行
tuning是实时配置而不是一次重新部署,这也正是为什么面板会把一个 Stat 的边界、一个冷却和一个掉落权重放在同一棵树里显示
declaring one是一次 schema 行为,不是一次玩法调用——这也正是为什么它的拒绝跟玩家会遇到的那些拒绝是不同的种类,而两者都在下面的“错误”里
deriving your own是组合,不是子类化:Crate 就是 world-objects 加 stats,而派生出来的东西仍然是一个 Entity 上的切面

错误

  • 声明一个 stat、一个 ability、一个 projectile、一张掉落表或者一个世界物体,是一次 schema 行为——fn 或 adm。一个玩家或客户端密钥去尝试,会得到 forbidden,而且什么都不会被声明,也不会被声明一半。那跟一名玩家在一次它被允许发起的调用里面遇到的那些拒绝,是不同的种类——在冷却中、付不起、缺一个 item:key.bronze——它们每一个都带着自己的代码。

一个 preset 其余的那些拒绝都是 Entity 的——一个 preset 不引入概念,所以它也不引入拒绝,而在这里再复述一遍,只会让读者为同一个答案有两个地方要查。有两件事是 presets 本身特有的:

  • 一个只填了一半的 preset 在部署时是一次校验失败。 一个 preset 承载的是一套自洽的东西:半份 Declaration 会在发出去之前被拒绝,而不是在一场对局里表现得古怪。
  • 一个 preset 不能被标上一个它自己的机制与之矛盾的属性——一个客户端没有规则可依的切面不能被声明为可预测,而这同样是在部署时被抓住的。

限制

那些上限都是 Entity 的——每个类型的切面数、每个类型的状态机数、已存实例的大小、单个实例上的变更速率。一个 preset 自己声明的那一条,是一个自有 preset 所携带的按所有者上限,它有三种边界行为之一,而且没有默认值:refuse、redirect 到一个已声明的所有者桶里、discard with event。数字随平台限制那一章一起落地。

用户流程

一发炮弹,从扣下扳机一直到射击者脚边的那个箱子。有四个 presets 参与其中——ability、projectile、stat 和 drop-table——而它们没有一个是你需要挂载的模块。

One shell from the trigger pull to the crate: four presets take part — ability, projectile, stat and drop table — and not one of them is a module you mount
The live game

Rooms

一个 Room 是一场游戏会话;平台不在乎是什么在托管它。 一个抽象覆盖了每场对局一台专用服务器、一张被切成若干逻辑层的大共享地图、一个由 master-client 托管的 Room,以及一个后端托管的小游戏。Room 的内部实现是我们的;你是从外部驱动一个 Room 的。

Who addresses Rooms, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 你的游戏有会话——对局、大厅、地下城、竞速——而必须有什么东西拥有它们的生命周期、成员关系和重连。
  • 你托管在专用服务器上、某名玩家的 master-client 上、或者后端自己身上,并且需要把玩家路由过去。
  • 一张共享地图必须跑许多逻辑会话——层,由 Visibility 限定作用域。
  • 玩家在会话中途加入,并且必须看到当前的真相——到达时这个 Room 的状态,然后是实时流量。
  • 一次掉线不能让人丢掉座位——模板的宽限窗口(battle 里是 45 秒)会恢复同一份成员关系。
  • 不必用它:一个功能纯粹是基于记录的请求/响应时——普通的 Data & Subscriptions 已经覆盖了它。

谁做什么

Actor在本页
room-owner在整个进程范围内注册 Rooms;在一个 Room 实例上——那套按实例的管理接口:热改配置、踢人、上锁、广播、销毁
entry-validator用一个代码和一个原因接受或拒绝加入请求
room-visitor浏览,带数据加入,在宽限窗口内重连,离开
spectator加入但不参与对抗;接收广播和实时流量
match-organizer预留会计入容量的座位;一个预留在模板所定的时限(battle 里是 90 秒)到期

一览

The battle template: capacity, tick, host kind, a named map, and the two seat windows
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Coming soon — Go

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

USTRUCT(PSRoomTemplate = (Name = "battle", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

不管它写在哪里,被推上去的模板都是带版本、可在面板里编辑的,所以 live-ops 重新调一种 Room 类型不需要重新部署引擎。托管由它构建出来的 Rooms,走的是同一套接口面,只是由另一个角色来触达,而一台 Unreal 专用服务器和一个 master-client 都完整持有它——注册、每个进程托管好几个、热改配置、踢人、发布、销毁。

The entry-validator hook: banned players rejected at the door, with a code and a reason
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

客户端:

Browse CTF rooms by filter, join with loadout data, react to arrivals
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

模型

一个 Room 是一个带规则的 Group,而它不是一个 Entity。 成员关系来自 Groups;它的系统状态是平台层面的,而游戏的状态住在被限定到它作用域里的 Entity 上。而且没有消费方代码在一个 Room 里面运行——两种权威模式下都是如此。

一个 Room 类型声明什么。

声明是什么
capacity以座位计,而一个座位是与成员关系分开的容量单位:它可以在一次加入之前被预留,并在闲置期间被保留着。一次预留是有时限的,带一个已声明的期限,过后这个座位无需加入就被释放
visibility可枚举、按名字或代码、隐藏
creation mode三者之一,而 on first join 模式有义务声明一个初始化 Hook
two independent timeouts闲置超时——一名参与者可以沉默多久;以及空 Room 的 TTL——一个里面没人的 Room 能活多久。两个不同的问题,所以是两份 Declarations
rejoin window在它之内,一次返回会恢复同一份成员关系和同一个座位,而不是造出一个新参与者
authority modeour simulation 或 external authority,而且没有默认值
trust in a reported outcome对于外部权威:接受它、用一个 Hook 核对它、不接受它。同样没有默认值
behaviour when the host drops等完宽限窗口、关闭这个 Room、准许一个接替者进来
map instance and world strata可选:它占用哪个实例,以及在它里面占用哪些层
room-scoped entities这家工作室的哪些 Entities 具有这个 Room 的作用域——用一个谓词表达,而不是用一套新机制

两台状态机。

属于状态
一个 Roomcreated → open → closed → torn down,其中 torn down 是终态,而 closed 意思是不再接受新的加入,而不是没了
一份成员关系active ⇄ inactive → departed,而 departed 对那份成员关系是终态

每一个 Room 都成立的事。

总是成立是什么
losing a connection and leaving是两个不同的事件,而这个窗口的结局是可观察的:“回来了”和“窗口过期了”是可分辨的,所以客户端绝不会被丢在那里猜是哪一种
no replay一次重连是从会话状态续上的;这个模块不承诺那段空档里的 Events
a spectator不是一个退化的参与者:在场,不占任何座位,而且不在被称作“那些玩家”的名册里——否则每一个针对名册的 operation 都得带一个条件
no in-room roles一个 Room 所有者是一个持有某项权利的 Actor(Access & Roles),不是成员名单里的一个级别
presence有历史,名册没有:谁加入了、掉了、回来了、走了,都被保存下来;名册的变化不是第二份历史
the interface是某个具体 Room 的:你面向的是这个 Room,不只是它的类型
three axes, not two横跨每一个 Room 的 API(浏览、注册、列出);任何成员都会调的按 Room 的 API(加入、离开);以及一套按实例的管理接口——对这个 Room 踢人、上锁、热改配置、关闭、销毁——它是对那一个实例持有管理或主机角色的人开放的,而不是对成员关系开放的
One room abstraction over three hosts — a dedicated server, a master client, the backend — identical declarations, different authority

两种权威模式。

模式谁在跑 Tick
our simulation我们的 Room 实现和它的那些模块
external authority一个跑着我们 SDK 的进程,在这个 Room 里以一个权威角色存在:这家工作室的游戏服务器,或者作为 master-client 的一台玩家客户端

这个模式决定什么,以及不决定什么。

是什么
the line是按角色画的,而不是按这个进程是谁的。一台专用服务器就是同一个客户端去掉渲染;把它和一台玩家机器分开的是信任,不是构造——这也正是为什么点对点不需要第三种模式,它就是一个处于外部权威模式、而权威是一台客户端主机的 Room
what is identical入场规则、在场状态、重连和每一份 Declaration,在三者之中都一样。不同的只有哪个进程持有权威,以及那个进程被授予了多少
the room does not move两种模式下都没有消费方代码在一个 Room 里面运行,而会话状态和持久状态在两者之中都留在我们这边。一个 master-client 是一个持有权威角色的成员:Tick 是在那里计算的,而这个 Room 并不住在那里
trust in the outcome是这个 Room 类型上一份单独的 Declaration——接受它、用一个 Hook 核对它、不接受它,而且没有默认值——而不是这个模式的一项属性

托管一个 Room。

Registering rooms from the pushed template: an idempotency key each, several per process
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Coming soon — Go

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

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — as a master-client build — a client that registers the room holds the same host surface at runtime
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
总是成立是什么
the handle就是客户端页签用的那同一个对象:没有主机引导,也没有只有服务端才有的句柄。它之所以能响应这些调用,是因为这个 Actor 的角色在运行时包含了它们——一台专用服务器或者一个 master-client 在一个主机密钥下运行(Access & Roles)
registration接收一个幂等键,因为一次超时在别的情况下就无从恢复了:用同一个键重复调用,你会拿回同一个 Room,而不是第二个没人知道地址的 Room

入场、在场状态与重连。

是什么
entry是一次被校验的请求:加入者在加入时提供数据,而入场 Hook 用一个代码和一个原因接受或拒绝。那份数据就是这名成员用来初始化自己的东西——在 battle 里,加入时带进来的那套配装,就是这名成员的 Tank 出生时所带的
seats and reservationsMatchmaking 按模板所定的预留时限——battle 里是 90 秒——占住一个位置,然后客户端直接加入。预留会计入容量,而到期会带着一个 Event 释放这个座位,而不是悄无声息地释放
a drop is not a leave一名断线的成员在宽限窗口(battle 里是 45 秒)内保住他的座位,并重连回同一份成员关系;过期会把它变成一次离开,而那个 Event 会带上到底是哪一种。晚回来一秒的人会被告知这个 Room 还活着而那份成员关系不在了——这是一个跟“没有这个 Room”不同的答案,而且是故意的
late join is state, not a journal一个加入者拿到的是这个 Room 的当前状态,然后是实时流量。他不在时发出的 Events 不会被重放,一名返回成员错过的那些也一样:任何必须挺过那段空档的东西都是状态——一名玩家埋下的地雷是一个被限定到 Room 作用域的 Entity,而不是一条得有人接住的 MinePlaced 消息

错误

  • 一个不存在或者被谓词藏起来的 Room,以及一个已被销毁的 Room,都回答 not found——一次拒绝绝不会泄露一个你不该看到的 Room。
  • 容量耗尽是一次冲突,而被预留的座位算作已占用;等一个座位空出来时值得再来一次。已关闭加入同样是一次冲突,如果它重新打开就值得再来一次。
  • 一次被某条规则拒绝的加入是一次冲突;被一个 Hook 拒绝则携带这个 Hook 自己的代码和原因,所以“一条游戏规则说不行”绝不会看起来像一次传输失败。
  • 重新加入窗口过期了是一次冲突:以一名新参与者的身份加入,占一个新座位。
  • 一个失效的预留是一次冲突:再拿一个。
  • 一个不可用或者不存在的地图实例是一次校验拒绝。
  • 一次被目标 Room 拒绝的迁移是一次冲突,而怎么办取决于它的原因。
  • 超出 Room 上限的创建回答为限流还是冲突,取决于超的是哪条限制。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 一个 Room 的容量——一次加入作为冲突被拒绝,而被预留的座位算作已占用。
  • 每个 Project 的 Rooms 数——创建作为冲突被拒绝。
  • 每个 Actor 的 Rooms 数——创建被拒绝,而已经创建出来的 Rooms 绝不会被销毁来腾地方。
  • Room 创建的速率——一次带期限的限流。
  • 一名参与者的闲置超时——带一个 Event 和一个已声明原因的强制离开。
  • 空 Room 的 TTL——带 Event 的销毁;在持久区域类型上可以关掉。
  • 一个座位预留的期限——带 Event 的释放。
  • 一个地图实例上的 Rooms 数——在一个已被占用的实例上创建会被拒绝,除非这个类型声明了共享占用。
  • 一个 Room Event 载荷的大小——发布在发送之前就被拒绝,绝不截断。

用户流程

一场在专用服务器上的对局,从玩家登录一直到 HUD 显示出谁加入了。

One match on a dedicated server: sign-in, a reserved seat, an entry the validator rules on, and the room state that arrives before any live traffic
The live game

谁看到什么,哪台机器在跑

两个听起来像一个的问题。《谁看到什么》说的是客户端:这个 Room 的状态里,哪一片会到达哪名玩家。《哪台机器在跑》说的是宿主:哪个进程拥有一个 Entity,以及接下来由哪个拥有。把两者搅在一起的那个词是 replication:在游戏引擎里它通常指第一个问题,而在这里它指第二个。

One room and two questions that sound alike, one page each, with the word “replication” sitting between them meaning the left one in an engine and the right one here
你想问的是就去读
哪个客户端收到哪份状态,以及收到多少Visibility,连同 Data 和 Prediction
哪台机器拥有这个 Entity,以及它死掉时会怎样What Survives Losing a Host

它们声明在两个不同的地方

两者都不是在运行时配置的,而且它们不共用一份 Declaration。

声明在它点名的是
谁看到什么aspect 上——Data、Visibility可见性谓词、物体上限及其顺序、哪些相邻区域可见,以及投递模式
哪台机器在跑Room 类型上——Rooms权威模式、对一个外部权威所报结果的信任程度,以及宿主掉线时的行为

它们还有一处不同:什么都不说时会发生什么。一个没有自己可见性规则的 aspect 会走共享包投递,那是默认值,对一个小 Room 来说也是对的。而一个没有点名权威模式的 Room 类型会被拒绝:这里没有默认值,因为没有任何东西能替你在我们的模拟和一个外部模拟之间做选择。

The live game

Visibility

40 名玩家时,整个 Room 的快照没问题。200 名时就不行了。 一个可见性区域决定谁收到什么,它是一个已声明的谓词,而不是你逐个物体去拨的开关。广播和按 Actor 分包是同一个模型的两种已声明投递模式,所以在两者之间切换是配置,而不是重写。它是一项通道优化,而不是一项权限——那件事参见 Access & Roles。

Who addresses Visibility, the 4 things it provides, and the 2 modules it builds on

同一个已声明的模型——谓词、层、细节层级——可以按两种方式来读。在两者之间切换是配置,不是重写,因为两者都是对同一份 Declaration 的读法。

广播按 Actor 分包
发送整个 Room,发给所有人每名玩家只收到他们的规则所选中的那一片
适用于小 Room;这是默认人群,那里包大小必须保持可预测
读这份 Declaration一次,为整个 Room按 Actor 各读一次

绝不能泄露的东西是不在包里,而不是在客户端被藏起来——从不发出去,这让它成为一项安全属性,而不是一项带宽属性。

何时使用

  • 你的 Rooms 长到超出了整房广播——200 名玩家需要的是按客户端的邻域流,而不是每一个 Delta。
  • 状态绝不能泄露:战争迷雾和只有所有者可见的字段应该是从不发送,而不是在客户端藏起来。
  • 好几场会话共享同一张地图,而且彼此不能看见——一个层就是多一个谓词。
  • 在人群里包大小必须可预测——给物体数量封顶并声明顺序,这样“最近的 N 个”才是一项承诺,而不是密度碰巧的结果。
  • 一名站在边界上的玩家必须能看到界外——把哪些相邻区域可见声明出来,因为默认只有他们自己所在的那个,否则一道边界读起来就是一堵空白之墙。
  • 不必用它:这个 Room 很小的时候——共享包这种投递模式已经够了。

谁做什么

Actor在本页
schema-author声明可见性谓词、物体上限和它的顺序、哪些相邻区域可见,以及投递模式
any订阅并接收这个区域所准许的东西;可以在已声明的范围之内为自己调低物体上限

一览

Radius and layer rules declared on Tank; Ammo scoped to its owner beside the field
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}
@Entity('tank')
@Visible({ radius: 60 })                       // spatial
@Visible(Rule.SameLayer)                       // layers of one map
export class Tank {
  @Sync() position!: Vector3;
  @Sync({ to: Scope.Owner }) ammo = 0;         // per-field scope
}
@entity("tank")
@visible(radius=60)                              # spatial
@visible(Rule.SAME_LAYER)                        # layers of one map
class Tank:
    position: Vector3 = sync()
    ammo: int = sync(to=Scope.OWNER)             # per-field scope
Coming soon — Go

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

// multi-entry values are one quoted list (a specifier value is a single token)
UCLASS(PSEntity = "tank",
       PSVisible = "radius:60, rule:SameMapInstance")   // spatial + instances of one map
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)                  FVector3f Position;
    UPROPERTY(PSSync = (To = "Owner")) int32     Ammo;   // per-field scope
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Visible(Radius = 60)]                           // spatial
[Visible(Rule.SameLayer)]                        // layers of one map
public class Tank
{
    [Sync] public Vector3 Position;
    [Sync(To = Scope.Owner)] public int Ammo;    // per-field scope
}

模型

一条可见性规则声明什么。

声明是什么
predicate规则本身,用的是跟访问谓词和转移守卫同一套谓词语言。一个半径、一个地图实例、一个队伍和归属关系,都是谓词的特例,不是各自独立的机制——契约里恰好只有这一套语言
object cap 和它的顺序一条规则可以给物体数量封顶,而这时选取顺序是声明出来的,不是推断出来的:“最近的 N 个”是一个谓词,加上按距离排序,再加上一个上限。光靠一个谓词表达不了它,因为谓词回答的是“这一行合不合格”,而不是“合格的里面哪个更近”
neighbouring areas来自相邻 Room 或地图实例的物体是否可见,以及具体是哪些。绝不隐含:没有声明时,这个区域就是接收方所在的那一个
delivery modeshared packet——给所有人同样的东西,省 CPU;或者 per-actor packet——按各自的区域各发各的,费 CPU,而且在人数大时是必需的

每一个区域都成立的事。

总是成立是什么
visibility不是一项权限:这个区域藏起来的东西可能靠权限拿得到,反之亦然。前者是一项通道优化,后者是安全——而把两者混为一谈,意味着一个战争迷雾设置会悄悄放宽权限,或者 ACL 被拿来省带宽,于是权利开始取决于距离
the recipient可以调低那个上限:在已声明的最大值之内,且绝不低于已声明的最小值,因为对每一个上下文匹配上的人来说谓词都是同一个,而包大小是接收方自己的问题
truncation是可观察的:接收方会知道这个包被裁过,以及是按哪种顺序裁的。悄无声息的截断是被禁止的——它和“本来就没有更多物体”无从分辨
degradation是声明出来的:当装配按 Actor 分包的预算用完时,平台按声明回退到共享包,而不是开始随意丢掉接收者:是更差,但差得有据可循,而不是一次和游戏 bug 无从分辨的泄露
packet shape是一项弱承诺:一个包的大小和构成应该不至于让人推断出被藏起来物体的存在,而这被有意写得比“必须”更弱——在真实体量下彻底隐藏流的元数据是做不到的。凡是“存在与否”的泄露要紧的地方,用权限,别用区域

错误

  • 一个未声明的订阅目标是一次校验拒绝。
  • 没有订阅权限回答 forbidden 还是 not found,取决于这个目标的存在是不是秘密——这次拒绝本身绝不能泄露它拒绝的是什么。
  • 一个解析不了的续接位置是一次错误请求,而不是悄无声息地从现在重新开始。
  • 一个被平台关闭的订阅,以及一个用尽的订阅数,两者都是冲突。
  • 加宽视野是 fn。授予一个越权视野和设定细节层级,对一个玩家会话都答以 forbidden,而它的视野原封不动:一个观战客户端没法给自己的授权加宽。
  • 一个被调用方视野排除在外的实例回答 not found,跟一个不存在的实例得到同样的答案——一个 forbidden 会坐实那堵墙后面站着什么。
  • 读取按 Actor 计的分包开销是 fn adm——一个云函数或者面板,绝不是一个客户端在问被人看着要花多少钱。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 一个按 Actor 计的包的开销——用尽时,带通知地按声明降级到共享包,绝不会随意丢掉接收者。
  • 每条规则的物体数——带一个已声明顺序和一个可观察截断标记地封顶。
  • 每个 Actor 的订阅数——新的那个被拒绝,已有的那些继续。
  • Delta 大小——这个 Delta 会被拆开而不是截断,而且这次拆分是可观察的。
  • 发送速率——一个上界,不是一项保证。

用户流程

一条半径规则把一个 200 人的 Room 变成按客户端的邻域流。

“谁看得到这个?”和“他们看到什么?”两个都可以查询,因为答不上来的那次调试才是昂贵的那次。按 Actor 计的分包开销是一等的读取项,代码里和面板里都是。

The live game

丢掉一台主机之后什么还在

一台主机在对局中途挂了。对局没有。 本页讲的是“replication”这个词的第二重含义——哪台机器拥有一个 Entity,以及下一台拥有它的机器是谁。第一重含义,也就是哪个客户端接收哪份状态,在 Visibility,配合 Data & Subscriptions 和 Prediction & Lag Comp。谁看到什么,哪台机器在跑是把这两者分辨开的地方。

A replacement host resumes from the last snapshot, so it has the state whole but as of that snapshot; the accent slice is the play a failover costs

Room 状态不会在主机之间复制

一个 Entity 在同一时刻恰好有一个所有者,也没有第二台机器保留一份随时可以接手的活副本。

两份副本同时接受同一发子弹,就必须就这两发子弹落地的顺序达成一致。每秒三十次地在多台机器之间就一个顺序达成一致,那叫共识——而共识把延迟放在了游戏最不能容忍的地方。单一的所有者没有这个问题,而下面每一套机制的存在,都是为了让单一所有者变得可幸存,而不是为了绕开它。

真正被复制的是在场状态:哪个 Actor 在哪个节点上。那是一个很小、变化很慢的事实,所以路由可以到处都知道它,而不必为任何会动的东西付出达成一致的代价。

被声明的状态保存在宿主之外

被声明的状态并不为持有它的那个进程所私有。它按已声明的间隔被快照,所以当前一台主机不再作答时,一台接替者可以从最后一份快照恢复,而玩家经由普通的 Rooms 宽限窗口重新进入。

由此有三件事,而这就是它诚实的形状:

  • 接替者拿到的状态是完整的,但只是快照那一刻的。 是完整,不是最新。一次故障转移的代价,是最后一份快照到这次损失之间的那段游戏内容,而那个间隔就是把这个最坏情况定下来的东西。
  • Tick 的连续性不会跨越权威的变更被带过去。 一次由平台执行的迁移会保住参与者的 tick 状态;而权威的一次更替不承诺这一点。Rooms 是这两者被声明的地方,连同宽限窗口走完之后会发生什么。
  • 任何你只保存在引擎 actor 里的东西,都随进程一起消失。 它从来没有被声明过,所以那台主机之外从来没有任何东西拥有过它。

一次部署走的是同一条路,只是没有损失

排空一台主机——不再往那里放新 Room,让在途的会话跑完或者交接出去,然后放它走——就是有意地、带着预告去跑一遍故障转移路径。这就是为什么“部署时不杀掉在线会话”不是第二套需要建起来并让人信任的机制:它就是这一套,只不过是有意启动的,而不是被一次崩溃启动的。

这个 Room 的主机得知此事的方式,跟它得知任何事的方式一样:平台会提前通知它,某个 Room 将因为平台自己那一侧的某个原因被关闭或者交接出去。

窗口走完之后会发生什么是声明出来的,而且没有默认值。 一个权威活在平台之外的 Room 类型,会为丢掉那个权威点名三种结果之一——等完一个已声明的窗口、关闭这个 Room,或者准许一个接替的权威进来。不说清楚不是这份 Declaration 提供的选项,因为另一种情况正是它存在所要防的那种失败:一个权威已死的 Room 仍然接受加入、仍然占着座位,向每一个参与者展示一场什么都不会发生的实时会话。

哪台机器不属于你的接口面

你从不点名一个节点。创建一个 Room 的人不选它在哪里运行,而且没有任何 operation 把主机当作一个参数——放置是平台的事,而且会一直是平台的事,好让它可以挪动一个 Room,而不必顾虑你的代码是照着它原先在哪里写的。

如果你自己托管 Rooms——一台专用服务器或者一个 master client——上面这些同样成立,只多一条:你会被告知要收摊,而在宽限窗口之内把你的那些会话跑完或者交接出去,是你的事。Rooms 是一台主机为那种绑定去注册的地方,而权威性解释了为什么这台主机只持有被授予的那些权利。

The live game

Matchmaking

把一名玩家送进对的那个 Room。 Tickets 描述这名玩家,并筛选其他人。Matchmaker 解出一次分配、预留一个座位,之后游戏流量就直接流向那个 Room。

Who addresses Matchmaking, the 4 things it provides, and the 3 modules it builds on
A ticket, a placement and a reserved seat — and the matchmaker leaving the path the moment the player joins the room

Matchmaker 只一次出现在路径上,用来决定你归属何处。它不在对局的路径上:它的结果是一次分配和一个有时限的座位预留,而从加入那一刻起,游戏流量径直走向那个 Rooms。所以一个繁忙的队列绝不会变成一场繁忙的对局。

何时使用

  • 你需要按已声明的标准——模式、区域、段位——把玩家路由进 Rooms,而不是手搓一份大厅列表。
  • 撮合标准必须来自平台数据,而不是客户端的主张:在入队前 Hook 里把段位盖上去。
  • 队列应该在服务端随时间放宽,而客户端只持有一张 ticket,从不轮询。
  • 队伍必须一起落进同一场对局——一个 Group 要么整体进去,要么完全不进。
  • 你在跑一个外部 matchmaker,只需要把它的决定落成分配 + 座位预留。
  • 不必用它:玩家自己挑会话时——Rooms 的浏览器和 Join 已经够了。

谁做什么

Actor在本页
player创建和取消自己的 ticket,并作为一支队伍的一部分进入
match-organizer声明 matchmaker 队列和放宽规则;读取分配结果
backend-service在入队前盖上可信的标准;执行外部 matchmaker 的决定

一览

Finding a match: one Find call returns a reserved seat to join
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);
# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)
Coming soon — Go

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

// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
    TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
    {
        if (!TicketResult.HasValue()) { return; }
        // the seat arrives as the ticket's outcome
        TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
        {
            Client->Rooms->Join(Seat,
                TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
                {
                    if (!JoinResult.HasValue()) { return; }
                    EnterMatch(JoinResult.Value());
                }));
        });
    }));
// 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 for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);
The ranked-duo queue declared: mutual filters and a two-step relaxation ladder
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}
@Matchmaker('ranked-duo')
export class RankedDuo {
  static size = Size.exactly(4, { multiple: 2 });
  static filter = "mode == 'duo' && region == self.region";
  static relax = [
    Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
    Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
  ];
}
@matchmaker("ranked-duo")
class RankedDuo:
    size = Size.exactly(4, multiple=2)
    filter = "mode == 'duo' && region == self.region"
    relax = [
        Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
        Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
    ]
Coming soon — Go

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

USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
                       Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
    GENERATED_BODY()

    UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
    UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Matchmaker("ranked-duo")]
public static class RankedDuo
{
    public static Size Size = Size.Exactly(4, multiple: 2);
    public static string Filter = "mode == 'duo' && region == self.region";
    public static Relax[] Relax =
    {
        Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
        Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
    };
}

那些不能信任客户端的标准,在入队前 Hook 里盖上去:

The pre-enqueue hook stamps the rank from platform data, not the client's claim
[Before(Matchmaking.Enqueue)]                     // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
    var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
    t.Properties["rank"] = rows[0].Rank;          // the row carries its rank in the full table
    return t;
}
// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
  const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
  t.properties.rank = rows[0].rank;               // the row carries its rank in the full table
  return t;
});
@before(matchmaking.enqueue)                      # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
    rows = await playserv.leaderboards.for_owners("ranked", [t.player])
    t.properties["rank"] = rows[0].rank           # the row carries its rank in the full table
    return t
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unreal client just calls Find above. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — the Unity client just calls Find above.

这次读取就是 Leaderboards 里那次按所有者列表的读取——跟一个好友群体所用的是同一个——而它返回的每一行都带着那位所有者在完整表中的名次。这个 Hook 之所以可以去要别人的那一行,是因为这块榜的谓词允许一个云函数这么做;一个玩家会话问同样的问题,只会拿到它自己的。

模型

一张 ticket 携带什么,而这两部分谁也化不成谁。

部分是什么谁信它
self-description这名参与者声明的属性——评分、模式、语言、选择的地图没有核对,谁也不信:它是调用方的主张
requirement其余人必须满足的一个谓词平台,因为是它在施加这个谓词

一张 ticket 的参与者是一个 Actor 或者一个 Group——一个 Group 整体进入,而那就是一支队伍。它的 ticket 是不可分割的:这个 Group 要么整份名册一起进,要么完全不进,因为把一个 Group 拆开会是另一种承诺,而那种承诺并不存在。

一个队列类型声明什么。

声明是什么
properties按名字和类型。一个没有在这里声明过的属性,在一张 ticket 里会作为校验失败被拒绝,而不是被忽略
roster size一个最小值、一个最大值,以及一个兼容步长——一份名册可被接受的那个倍数,所以“五人”队伍就是五人,而不是二到十之间的任何数
requirement ladder一组有序的、带窗口的谓词:每一级都是一个更宽的要求,外加一个过后就往下走的时间。放宽是一份 Declaration,绝不是某个处理函数里的随意逻辑
the predicate language就是其他一切所用的那一套,而它的词汇里包括这张 ticket 自己的属性——“评分在我的 ±100 以内”是能表达出来的。没有这一点,双边模型根本就跑不起来,因为相对条件正是它的全部意义
mutuality一份 A 接受 B、而 B 不接受 A 的名单,是否可被接受。没有默认值
ticket lifetime过后这张 ticket 带着一个 Event 转移到 expired
outcomeRoomPlacement——一个 Room 引用加上它里面的若干预留,用于同时进行的对局;或者 RosterSet——只有名单,没有 Room 也没有预留,用于对手不在线的异步对局

一张 ticket 的状态。 created → queued → matched、cancelled、expired,后三者是终态。

总是成立是什么
one live ticket per participant per queue第二张是一次冲突,而不是第二次申请——去读已有的那一张
the reason for a pairing是可观察的:它会到达 matchmaking Event 和历史里。对交付的那些算法而言,那就是阶梯的哪一级;一个覆盖它的实现可能根本没有级,那时理由就是它声明的一个不透明值——但总归有一个
expiry是一个结果,不是一个错误:“在已声明的时间内没有凑齐一份名单”是一次正常的完成,作为这张 ticket 的结果投递出来
the outcome靠订阅到达:不靠轮询。撮合要花几秒到几十秒,所以轮询会把等待变成随队列长度增长的负载——客户端持有一张 ticket,之后再也不问
losing the connection cancels the ticket是声明出来的,而不是推断出来的:一张 ticket 是现在就要玩的申请,而把一名不在场的玩家撮进去,会让这份名单对其他所有人都更糟
matched是原子的:对 RoomPlacement 来说,要么这份名单撮合成功、每一名参与者都持有一个预留,要么这些 tickets 留在队列里。对 RosterSet 来说,原子的结果就是那份名单本身

错误

  • 同一个队列里的第二张 ticket是一次冲突;别再来一次,去读已有的那张 ticket。
  • 一个未声明的属性,或者一个点名了它的要求,是一次校验失败——不是一次悄无声息的忽略,那会在之后以“没找到对手”的形式浮出来。
  • 队列被暂停了回答 unavailable,而不是 forbidden:调用方的权利完好无损,而且情况是暂时的,所以带退避地重试是对的。
  • 结果所需的 Room 创建不了同样是 unavailable,带退避。
  • 预留失败了是一次值得重试的冲突——这张 ticket 留在队列里。
  • 一张找不到的、或者属于别人的 ticket,以及一个被谓词拒之队列门外的 Actor,两者都回答 not found,所以一次拒绝既不泄露这张 ticket,也不泄露这个队列。
  • “没有凑齐一份名单”绝不是一个错误——见上面的过期。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 一个队列里的 tickets 数——创建作为冲突被拒绝,而且已有的 tickets 不会被逐出来腾地方。
  • 一张 ticket 的寿命——带着一个 Event 转移到 expired。
  • 阶梯的级数——一份级数过多的 Declaration 在声明时就被拒绝。
  • 一张 ticket 里的 Group 大小——这张 ticket 作为校验失败被拒绝。
  • 每个队列类型的已声明属性数——在声明时被拒绝。
  • Ticket 创建速率——一次带期限的限流拒绝。
  • Matchmaking 历史的保留——过了那个时段,一条记录就按已声明的时段不再可读。

用户流程

从登录一直到站在对局 Room 里,段位在服务端被盖上。这段旅程从 Auth & Players 开始,因为一张 ticket 有一个所有者:没有会话,就没有人可以入队。

One ticket from sign-in to a seat: the rank is stamped server-side before the queue, and the matchmaker leaves the path once it has placed you
The live game

Map

静态的世界:边界、地形、障碍物,以及“东西能去哪儿?”。 物理模型被有意做得比视觉模型简单得多:带一个占地轮廓和一个高度的基元、带规则的层,以及一个其他每个模块都复用的有效位置查询。

Who addresses Map, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 你需要一个服务器可以查询、而不只是渲染的静态世界——边界、地形、障碍物。
  • 出生点、掉落和装饰必须落在合法的点位上:一个基于规则的 RandomPosition 查询,没有旁路。
  • 竞技场应该每场对局重新生成——一个已声明的 Seed 在一份 bug 报告里能复现出同一张地图。
  • 箱子和墙会被打碎又回来——带 HP 和重生计时器的可破坏物。
  • Bots 和 Entity Presets 需要针对障碍物集合的射线检测和视线判定答案。
  • 不必用它:这个世界纯粹是视觉的、没有任何服务端代码去问“东西能去哪儿”时。

谁做什么

Actor在本页
schema-author声明地图、障碍物基元、可破坏物、层和它们的规则
room-owner把一张地图绑到一个 Room 上;请求出生位置;做射线检测
operator从面板里放置或移除障碍物和层

一览

The arena layout declared: seed and bounds, terrain, rocks, respawning crates, a rules layer
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset
@Map('arena', { seed: 42, bounds: '160x160' })
export class Arena {
  @Terrain({ heightNoise: 0.3 }) height: Terrain;                   // 3D height field
  @Scatter('rock', { count: 40, minSpacing: 6 }) rocks: ObstacleSet;
  @Destructible('crate', { count: 12, hp: 100, respawnAfter: '30s' }) crates: ObstacleSet;
  @Layer('ground', { notInside: 'water' }) ground: Layer;
}
// or: Maps.named('arena-caves-v3')  — authored in the panel or loaded from an asset
@Map("arena", seed=42, bounds="160x160")
class Arena:
    height = terrain(height_noise=0.3)                  # 3D height field
    rocks = scatter("rock", count=40, min_spacing=6)
    crates = destructible("crate", count=12, hp=100, respawn_after="30s")
    ground = layer("ground", not_inside="water")
# or: maps.named("arena-caves-v3")  — authored in the panel or loaded from an asset
Coming soon — Go

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

USTRUCT(PSMap = (Name = "arena", Seed = 42, Bounds = "160x160"))
struct FArena
{
    GENERATED_BODY()

    UPROPERTY(PSTerrain = (HeightNoise = "0.3"))                             FPSTerrain Height;  // 3D height field
    UPROPERTY(PSScatter = (Obstacle = "rock", Count = 40, MinSpacing = 6))   FPSObstacleSet Rocks;
    UPROPERTY(PSDestructible = (Obstacle = "crate", Count = 12, Hp = 100,
                                RespawnAfter = "30s"))                       FPSObstacleSet Crates;
    UPROPERTY(PSStratum = (Name = "ground", NotInside = "water"))            FPSStratum Ground;
};
// or: PS::Maps::Named(TEXT("arena-caves-v3")) — authored in the panel or loaded from an asset
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Terrain(HeightNoise = 0.3f)]                 public static Terrain Height;  // 3D height field
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
    [Destructible("crate", Count = 12, Hp = 100, RespawnAfter = "30s")] public static ObstacleSet Crates;
    [Layer("ground", NotInside = "water")]        public static Layer Ground;
}
// or: Maps.Named("arena-caves-v3")  — authored in the panel or loaded from an asset

Scatter 和 Destructible 是放置生成器,不是运行时的摇号。一个生成器在地图版本发布时就解出结果:那四十块石头变成四十个已声明的基元,而发布出去的版本携带的是这些基元,不是那条规则。因此同一个 Seed 在对局里、在录像回放里和在 bug 报告里,给出的是同样那四十块石头——而几何限制只在这个解出来的集合上检查一次,在这个版本抵达某个 Environment 之前。

其他一切都会问的那个查询:

RandomPosition: a fair spawn on ground, away from players, never repeating
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});
const spawn = map.randomPosition((r) => {
  r.layer('ground');
  r.awayFrom(players, { minDistance: 12 });
  r.noRepeat({ lastN: 3 });
});
spawn = map.random_position(rules=lambda r: (
    r.layer("ground"),
    r.away_from(players, min_distance=12),
    r.no_repeat(last_n=3),
))
Coming soon — Go

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

// Dedicated-server host: place a spawn through the same rule-based query
Map->Positions->GetRandom({ .Stratum = PSKeys::Strata::Ground,
                            .AwayFrom = Players,
                            .MinDistance = 12.f,
                            .NoRepeatLastN = 3 },
    TPSOnResult<FVector>::CreateLambda([](const TPSResult<FVector>& Result)
    {
        if (!Result.HasValue()) { return; }
        PlaceSpawn(Result.Value());
    }));
// 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 — a master-client build runs the same query; a plain client is spawned at the resulting position
var spawn = map.RandomPosition(r =>
{
    r.Layer("ground");
    r.AwayFrom(players, minDistance: 12);
    r.NoRepeat(lastN: 3);
});

模型

The physical model as a footprint and a height on two layers rather than a mesh, with one valid-position query every other module reuses

两个层,由不同的人来声明。

层它装什么,以及谁来声明它
static带高度的地形、障碍物基元、边界和地点——由人创作的内容
dynamic在运行时由 Entities 带来的障碍物:门、可破坏物、平台。所以一个可破坏物是一个带生命周期、状态和所有者的 Entity,而它只在“它带来一个障碍物”的那一部分上才成为地图——一块静态的石头是在地图里声明的,一扇门是一个带来障碍物的 Entity。这些在这里没有属于自己的生命周期:生命周期属于那个 Entity

一张地图声明什么。

声明是什么
key 和 version一张地图是由人创作的内容:在代码里声明,按 key 寻址,并且带版本——版本是一个 Room 所引用内容的一部分。改动一个已发布版本的几何是被禁止的;一次编辑就是一个新版本
terrain一个高度场——一个带已声明步长的规则网格,而这个步长是一条已声明的精度限制,所以一次高度查询是按它作答的,而不是精确作答。地形可以缺席:一个悬在虚空里的竞技场是合法的
obstacles一个封闭集合的基元——盒、球、胶囊、带已声明顶点上限的凸包。任意三角网格不被接受,而这正是服务端检查得以可能的前提条件
每个障碍物的 passability kind不可通行、对某个已声明类别可通行、只挡视线。同一个基元既能当墙又能当灌木丛,而区别是声明出来的,不是建模两遍
bounds在它之外位置就不可接受的那个体积
world strata一张地图内部已声明的空间层——地面、地下、空中。这些是几何和寻址方面的 Declarations
locations被命名的地点或区域——一个出生点、一个占领区、一条走廊。一个地点回答的是在哪里,绝不是会发生什么:它不携带任何游戏逻辑
placement generator可选:一条产出基元的规则——一个数量、一个最小间距、一片区域、一个种子。它在一个版本发布时被解出,按种子确定性地解出,而在那之后地图持有的是基元,不是规则

一个世界层和一个地图实例绝不是同义词。

是什么
world stratum地图内部的一份声明——地面、地下、空中
map instance已发布地图的一份独立的运行时副本。各个实例共享那份不可变的已发布几何,而各自拥有独立的动态障碍物和独立的 Entity 名册。一个 Room 占用一个实例,并且可以在它内部选择若干层

每一次查询都成立的事。

总是成立是什么
one geometric canon所有几何都在平台已声明的坐标规范里,而每一个几何字段的精度都声明在那个字段上
the world model是一次简化:服务器的几何不是美术模型,也没有义务是
an answer names its instance and its moment一次查询是由地图的静态层加上它所询问那个实例的动态障碍物来作答的,而它会声明这个答案在哪一刻为真——动态障碍物会变,所以这个答案是一份快照
the values是 managed 的,不是 seed 的:几何不是设计师每天要调的东西:一次来自管理控制台的编辑会被拒绝,而不是被悄悄留下
movement and contact不在这里解算:它回答的是这个空间是什么样的;一个位置可不可接受、以及响应是什么,属于 Collision,而把它施加出去属于 Locomotion

错误

  • 一张找不到的地图、版本或实例回答 not found,一个被撤回的版本同样如此——重复没有意义。
  • 在一个已存在的版本下发布改动过的几何是一次冲突:做一个新版本。
  • 发布失败落在声明时、部署时,绝不落在运行时——一张超出基元上限的地图、一个超出顶点上限的凸包,以及一个拿任意网格当障碍物的做法,全都是在任何东西发出去之前的校验拒绝。
  • 在边界之外的一次高度查询不是错误——它就是那个已声明的答案“在边界之外”,而且它跟“在一个障碍物里面”是可分辨的,因为在一种情况下客户端会掉头,在另一种情况下会绕行。
  • 查询速率超了在限流类别下作答,带一个期限。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 每张地图的障碍物基元数、凸包顶点数、高度场分辨率、边界的大小、每张地图的地点数——这里的每一条都是在发布时被拒绝的,不是在查询时:一张发得出去的地图,是一张本来就装得下的地图。
  • 每张地图的地图实例数——再创建一个作为冲突被拒绝;已有的实例绝不会被释放来腾地方。
  • 保留的版本数——最老的那个正在弃用的版本被撤回,而一个下面还有活跃 Room 的版本绝不会。
  • 对空间的查询速率——一次带期限的限流。

用户流程

一次排定的空投向地图要一个合法的点位,然后一名玩家开过去把它捡走。掉落表是一个 Entity Presets——是一个 Entity 上的 Declaration,不是一个你要挂载的模块。

A scheduled airdrop from the query to the pickup: the map answers where a thing may go, collision answers whether it fits, and the transfer into inventory is what the HUD renders
The live game

Collision

把一个变换绑到障碍物地图上;声明接触会做什么。 碰撞在平台的模拟内部运行。你声明 body、层和响应,然后订阅接触。

Who addresses Collision, the 4 things it provides, and the 2 modules it builds on

何时使用

  • 移动中的 Entities 必须在服务端解算接触——滑动、停下、弹开——而不必手写一套偏转例程。
  • 玩法要对触碰作出反应:拾取物在重叠时被收走,触发体积点燃一个 Entity 状态机。
  • Locomotion 和 Entity Presets 需要针对 Map 障碍物集合的扫掠解算。
  • 放置预览或者瞄准需要“这东西放这儿装得下吗?”和体积重叠查询。
  • 不必用它:没有任何东西在物理上相遇时——基于记录的请求/响应玩法就是普通的 Data & Subscriptions。

谁做什么

Actor在本页
room-owner声明 body、层和响应;查询重叠和接触

这适用于哪些 Room。 这个模块运行在平台推进模拟的地方——声明了 Host = "Backend" 的 Room。如果拥有模拟的是你自己的 game server(把 PlayServ 当作元服务器),那么移动、碰撞和预测都留在引擎一侧,而这一页描述的是平台托管的那个替代方案,不是一项要求。

一览

形状、层,以及接触会做什么,全都坐在这个 body 自己身上——没有任何东西从远处去声明层的配对:

A capsule Body on the tank: vehicles layer — sliding off walls, passing through pickups, crates decided per contact
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;

  @Body({ shape: 'capsule', radius: 0.6, layer: 'vehicles' })
  @CollidesWith('walls', Response.Slide)
  @CollidesWith('pickups', Response.Pass)        // reported, motion passes through
  body: Body;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()

    body = collision.body(shape="capsule", radius=0.6, layer="vehicles",
                           collides_with=[
                               ("walls", Response.SLIDE),
                               ("pickups", Response.PASS),   # reported, motion passes through
                           ])
Coming soon — Go

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

// the declaration rides inside the engine's own reflection macros, in the specifier position —
// UHT reads it from the header text, and the member is a reflected property at the same time
UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync)
    FVector3f Position;

    // walls slide, pickups report the contact and let motion pass through —
    // multi-entry values are one quoted list (a specifier value is a single token)
    UPROPERTY(PSBody = (Shape = "Capsule", Radius = "0.6", Layer = "vehicles"),
              PSCollidesWith = "walls:Slide, pickups:Pass")
    FPSBody Body;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;

    [Body(Shape.Capsule, Radius = 0.6f, Layer = "vehicles")]
    [CollidesWith("walls", Response.Slide)]
    [CollidesWith("pickups", Response.Pass)]        // reported, motion passes through
    public Body Body;
}

一次接触是一个 Event,而各个模块订阅它。在一次接触上没有 Hook:等到一次接触存在时,这一步已经把它解算完了,所以没有什么可以拒绝了。当一家工作室需要不同的规则时,它去覆盖可接受性检查和路径检查的实现——参见 Extensibility——而响应仍然是声明出来的。

模型

一个 body 声明什么。

声明是什么
shape来自一个封闭集合的基元——球、胶囊、盒——带已声明的尺寸。不提供任意网格,这跟 Map 里服务器世界模型的约束和理由完全一样
where it lives在一个切面上,跟变换在一起:那就是策略的单位,而一个 body 与它的变换同命运
how its path is checkedstepwise——检查这一步的最终位置,快,而且一个快速的 body 会穿过一个薄障碍物;或者 swept——检查两个位置之间的那一段,更贵,而且在一步之内排除了穿模。这是声明出来的,绝不由某个实现按速度去挑:一发子弹能不能飞穿一堵墙,是游戏的一项属性,不是一次优化
areas it participates in它被算作处在哪些体积之内
its relation to the art model不要求有任何关系:一个 body 是一次简化,而与美术模型的偏离在已声明的范围之内是可接受的

响应是声明在一个配对上的——一个障碍物的通行种类 × 一种 body 类型——而它来自一个封闭集合:

响应它意味着什么
stop移动在最后一个可接受的位置停下
slide移动沿着这个障碍物、按任何可接受的分量继续
bounce方向被反射,速度乘上一个已声明的系数
damp移动继续,速度乘上一个已声明的分数
pass这个障碍物不影响移动,但这次接触仍然是可观察的
cease to exist这个 Entity 就此结束——一发子弹撞上一堵墙

这些系数是已声明的值,而不是从质量和材质算出来的——这份契约里两者都没有。

每一次检查都成立的事。

总是成立是什么
every pair都有一个响应:一个缺失的配对是一个声明缺陷,在部署时就被拒绝,而不是在交火中才碰上
the response table客户端可读:跟权威用来计算的是同一张表,所以给定同一份 Declaration,一个客户端和一台服务器对同一次接触会给出同样的答案
reproducible within one authority, not across platforms同样的输入按同样的顺序,在同一个进程和同一个构建里给出同样的结果。在不同平台和不同构建上得到逐位相同的结果不在承诺之列,而一个建立在“碰撞在哪儿算都一模一样”这个假设之上的网络模型,是建在沙上的
simultaneity是声明出来的:当两个移动中的 body 在同一步之内相撞时,解算顺序是声明出来的、确定的。存储的遍历顺序、输入到达的顺序和随机性,都不可以成为它的依据
one contact, one fact两个 body 之间的一次接触,被双方观察为一个带单一标识符的单一事实,而不是两个各自独立的 Events
extension points sit on the step, not on a contact这一步之前变换可以被改动,之后就只有观察。一次接触已经发生了,所以没有什么可以拒绝;不同的规则是对可接受性检查和路径检查的一次已声明覆盖,而这样一次覆盖有义务对客户端同样可用
the module自己不挪动任何东西:它回答一个位置可不可接受、以及响应是什么;把它施加出去是 Locomotion 的事
a contact is an event这正是各个模块去订阅而不是耦合的原因:一个陷阱的状态机把一次转移绑到一次触发体积的进入上,Entity Presets 在重叠时收走战利品,而 Entity Presets 通过这个模块的扫掠来解算命中

错误

  • “不可接受”是一个答案,不是一个错误,而且它会点名三个理由中的哪一个:在边界之外、被一个静态障碍物占着,或者被另一个 Entity 的 body 占着。客户端对这三者的反应各不相同——掉头、绕行,或者等待——所以把它们压成一个“不行”是要损失行为的。
  • 声明层面的失败落在部署时,绝不落在第一次接触时:一个形状在封闭集合之外的 body、一个待在没有变换的切面上的 body,以及一个没有已声明响应的配对,全都在部署时被拒绝。碰撞是在交火中发生的,而那里的一次运行时失败,被观察到的样子是一堵凭空消失的墙。
  • 这个 Entity 或者这个地点找不到回答 not found,而重复没有意义。
  • 检查速率超了在限流类别下作答,带上那个在此之前重试都没有意义的期限。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 一个 Room 里的 body 数——再声明一个作为冲突被拒绝;已有的 body 绝不会被移除来腾地方。
  • 每一步的接触数——超出的部分绝不被悄悄丢弃:要么这一步被拒绝,要么截断的顺序是声明出来的。
  • 一个 body 的尺寸相对地图的网格步长——在部署时被拒绝,因为一个比高度场步长还小的 body 会掉穿地形,而那不能是一次运行时的意外。
  • 一个 body 可以同时待在多少个区域里——超出的部分在部署时被拒绝。
  • 每个 Actor 的检查速率——一次带期限的限流。
  • 一个“谁在这片区域里”答案中的 body 数——按一个已声明的顺序截断,而且截断标记是强制的。

用户流程

一个触发体积、一个状态机和一扇门:接触 Events 把所有的线都接好了。压力板和门都是世界物体——Entity Presets,不是你要挂载的模块。

A pressure plate, a state machine and a door: a contact is an event and nothing hooks it, because by the time it exists the step has already resolved
The live game

Locomotion

你声明一样东西怎么动;没有人去写积分器。 一个运动模型把带序号的输入变成权威的运动,与 Collision 一起积分,为 Prediction & Lag Comp 记录下来,并被增益、减益和地形修饰。

Who addresses Locomotion, the 4 things it provides, and the 3 modules it builds on

何时使用

  • Entities 在玩家输入下移动——坦克、角色、载具——而运动必须是服务端权威的。
  • 你宁愿声明速度、加速度和转向速率上限,也不想写一个积分器。
  • 玩法会把 body 推来推去:Impulse 击退、Teleport,以及带持续时间的泥地类修饰量。
  • 移动必须感觉起来是即时的:同一个已声明的模型既在服务器上推进,也在 Prediction & Lag Comp 的循环里推进。
  • 不必用它:位置只按离散步骤变化时——Entity 上的一个同步字段已经够了。

谁做什么

Actor在本页
schema-author声明运动模型、约束和绑定
room-owner从主机一侧施加冲量、传送和修饰量
player提交带序号的输入;读取运动状态

这适用于哪些 Room。 这个模块运行在平台推进模拟的地方——声明了 Host = "Backend" 的 Room。如果拥有模拟的是你自己的 game server(把 PlayServ 当作元服务器),那么移动、碰撞和预测都留在引擎一侧,而这一页描述的是平台托管的那个替代方案,不是一项要求。

一览

Declaring the Tank movement model: Locomotion.Tank with speed, acceleration and turn-rate limits
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}
@Entity('tank')
export class Tank {
  @Sync() position!: Vector3;
  @Motion({ model: 'tank', maxSpeed: 8, acceleration: 14, turnRateDeg: 120 }) motion: Motion;
}
@entity("tank")
class Tank:
    position: Vector3 = sync()
    motion = locomotion.motion(model="tank", max_speed=8.0, acceleration=14.0, turn_rate_deg=120.0)
Coming soon — Go

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

UCLASS(PSEntity = "tank")
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync) FVector3f Position;

    UPROPERTY(PSMotion = (Model = "Tank", MaxSpeed = "8.0", Acceleration = "14.0", TurnRateDeg = 120))
    FPSMotion Motion;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
public class Tank
{
    [Sync] public Vector3 Position;
    [Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
    public Motion Motion;
}

客户端输入是一个带序号的意图。平台来推进这个运动:

Client input as sequenced intent: Motion.Drive sent at input rate, stepped server-side
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate
room.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 });   // cl — sent at input rate
room.my(Tank).motion.drive(throttle=1.0, steer=-0.4)   # cl — a bot brain drives the same way
Coming soon — Go

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

Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this](const TPSResult<UTank*>& Result)
    {
        if (!Result.HasValue()) { return; }
        // client — sent at input rate, numbered so the platform can acknowledge
        Result.Value()->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
    }));
// 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
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f);   // cl — sent at input rate

服务端一侧的动词:

Server verbs: a knockback impulse, a 3-second mud modifier, a clean teleport
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);
tank.motion.impulse(knockback);
tank.motion.modify('mud', { speedMultiplier: 0.6, duration: seconds(3) });
tank.motion.teleport(spawn);
tank.motion.impulse(knockback)
tank.motion.modify("mud", speed_multiplier=0.6, duration=seconds(3))
tank.motion.teleport(spawn)
Coming soon — Go

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

// on a dedicated server / master-client host
Tank->Motion->Impulse(EPSImpulseKind::Impulse, KnockbackVelocity);
Tank->Motion->Modify({ .Modifier = TEXT("mud"), .SpeedMultiplier = 0.6f, .For = FPSDuration::Seconds(3.f) });
Tank->Motion->Teleport(SpawnPosition, SpawnFacing);
// 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 — a master-client build holds the same host verbs; a plain client sees their results as predicted, reconciled motion
tank.Motion.Impulse(knockback);
tank.Motion.Modify("mud", speedMultiplier: 0.6f, duration: 3.Seconds());
tank.Motion.Teleport(spawn);

模型

一个 Entity 为了能动,要声明什么。

声明是什么
movement model交付集合中的一个——转向式、坦克式、角色式、载具式、飞行式——作为同一个 step 的若干实现,带已声明的选择条件和一个默认值。这个 step 是一个纯粹的 (pose, input, dt) → pose 函数
parameters客户端可以读到的已声明值;没有它们,预测就会系统性地跑偏。它们是 seed——设计师要调它们,而一次部署不能悄悄弄丢那些编辑——而反作弊所倚靠的那些限制可以是 managed,那时来自管理控制台的编辑会被拒绝
limits最大速度、加速度和刹车、每次输入的最大转角、倒车倍率,以及部件单独的旋转速度。每次输入的转角跟旋转速度是有意分开声明的:一个约束的是一次瞬时跳变,另一个约束的是一个连续速率,它们是两种不同的防线
behaviour on stale inputstop、continue until a declared deadline,或者 continue indefinitely。不存在“照旧继续”的默认值——那样一个断了网的玩家就会一直开下去
pose tolerance一个被主张的姿态可以离服务器的有多远,而且它可以按状态不同:站着、移动中,以及刚重生之后,是三种不同的容差
step rate and catch-up cap这个 step 多久跑一次,以及当服务器落后时一次最多可以推几步
impulse kinds每一种都带它的量值和它衰减的方式

每一步都成立的事。

总是成立是什么
the module owns一个 Entity 随时间变化的位置和朝向,别的都不管。这些位置的历史由 Entity 保存,而不是在这里,所以“玩家 300 毫秒前在哪儿”恰好只有一个答案,而不是两个周期不同的缓冲区
authority归服务器:在 our simulation 模式下,客户端发的是一个意图,绝不是一个结果
collisions不在这里解算:它去问 Collision 一个位置可不可接受、以及响应是什么,而自己不保留任何响应表
input是一个意图:“前进”“向右”“把炮塔转到那边”——来什么收什么,因为它对世界不作任何断言
a claimed pose是一项主张:绝不是事实。超出已声明的容差,它会被夹到最近的那个可接受姿态,而这会产生一个可观察的 pose_clamped
input sequencing是必需的:同一个序号绝不会被应用两次,而更小的那个会被丢弃
a limit是夹住,不是拒绝:“这一 tick 前进十米”会变成可接受的那个值,而不是一个错误。正是这一点让一条限制在构造上就是反作弊——服务器物理上就产不出那个非法的姿态——也正是这一点让客户端不至于每一帧都被拒绝淹没
an impulse obeys the same constraints后坐力、一次推搡、一次爆炸和击退都是从输入之外来的,而它们没有一个能绕开 Collision:后坐力不会把一辆坦克顶进一块石头里
identical rules, not identical bits不承诺跨平台逐位相同的结果。承诺的是同样的规则,以及同一个权威内部的可重现性
the step是纯粹的、由 tick 驱动的:同一段代码既在服务器上推进运动,也在客户端的预测循环里推进,而正是这一点让和解变得精确

错误

  • Entity 上缺少运动模型、一个只填了一半的 preset,以及一个没有已声明衰减的冲量,全都是部署时的校验失败,而不是运行时的——一个没有尽头的冲量是一个声明缺陷,所以它绝不会到达一名玩家。
  • 期望的世代不匹配是一次前置条件失败,值得在重新读取之后再来一次:在一次重生之前发出的输入,绝不能在重生之后被应用。
  • 输入速率超了在限流类别下作答,带一个期限。
  • 这个 Entity 不可控是一次冲突,而只有在状态变化之后重复才有意义。
  • 有三样东西在两个方向上都不是拒绝,而且三者都是可观察的。 过期的输入被丢弃,一个超出限制的意图被夹住,而一个超出容差的姿态被夹住并报为 pose_clamped。悄无声息地做这三者中的任何一个,都会让客户端以为它应用成功了,并从此与服务器永久跑偏。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 最大速度和加速度——夹住,绝不拒绝。
  • 每次输入的最大转角——夹住。
  • 每个 Actor 的输入速率——一次带期限的限流。
  • 追赶步数——超过上限的步数会被丢弃,并带一个已声明的后果:模拟时间落后,而那是可观察的,而不是在一次跳跃里追平,那看起来像所有人同时瞬移。
  • 冲量量值——夹到已声明的最大值。
  • 每个 Entity 同时生效的冲量数——新的一个把最老的那个挤出去,而这次挤出是可观察的;不存在悄无声息的无界累加。
  • 一个被主张姿态的寿命——比已声明时段更老的那个不予考虑。

用户流程

一次击退的旅程:player 在开车,另一辆坦克里的 attacker 开火,而这个冲量作为一个和解过的姿态落在受害者的屏幕上。那个 ability 和那发 projectile 都是 Entity Presets——是 Entities 上的 Declarations,不是你要挂载的模块。

One knockback end to end: input is an intent, an impulse arrives from outside it, and neither bypasses collision — the victim’s screen sees a reconciled pose
The live game

Prediction & Lag Comp

玩家 50 毫秒前按下了跳跃。数据包刚刚才到。他们没有掉下去。 在携带真实事件时间的数据之上做向前预测和向后补偿:客户端感觉是即时的,服务器保持正确,而命中在射击者的时间线里被判定。

Who addresses Prediction, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 在延迟之下输入必须感觉即时,同时服务器保持权威——向前预测,在分歧处和解。
  • 命中必须在射击者的时间线里被判定:ResolveAt 把判定框回溯到上报的视角 tick。
  • 瞄准弧线和落点标记必须和结果一致——客户端和服务器预报同一条 Trajectory。
  • 对游戏至关重要的字段绝不能回滚——把什么会预测、什么要等服务器声明清楚。
  • 拉扯需要调参:按 Entity 的窗口、容差和错误预测遥测。
  • 不必用它:延迟不伤人时——回合制或者节奏慢的游戏,跑普通的 Data & Subscriptions Deltas 就很好。

谁做什么

Actor在本页
schema-author声明哪些字段可预测、哪些只由权威决定;设定预测窗口
room-owner在一个历史状态上解算命中;回溯世界
player预测并和解运动;订阅纠正

这适用于哪些 Room。 这个模块运行在平台推进模拟的地方——声明了 Host = "Backend" 的 Room。如果拥有模拟的是你自己的 game server(把 PlayServ 当作元服务器),那么移动、碰撞和预测都留在引擎一侧,而这一页描述的是平台托管的那个替代方案,不是一项要求。

一览

这个词涵盖三样不同的东西,它们绝不能被合并,而每一样都有自己的文章。它们有不同的权威和不同的失效模式——三者共用一个词,意味着调其中一个会悄悄改掉另外两个。

机制它做什么跑在哪里它出错时
预测你自己的移动把已声明的模型施加到你自己的输入上,而不等服务器客户端一次纠正,重放并平滑
显示其他玩家在到达的那些状态之间把其他 Entities 画出来客户端一次看得见的抖动
延迟补偿把目标回溯到射击者看到的那一刻服务器有人死得不公平

本页是那个枢纽:共享的模型、共享的 Declarations,以及那些帮你挑好一种组合的 presets。三种机制各自到底是怎么回事,在那三篇文章里。

四个 presets,而“不预测”是其中之一。

Preset预测你自己的做补偿平滑别人
shooter是在大约一秒半的窗口里是
arcade是否是
observer否否是
不预测否否否——状态带着一个已声明的插值窗口从权威那里到来

最后那个不是个占位。回合制游戏、策略游戏和大多数手游根本不想要预测,而一句声明出来的“我们不预测”会告诉客户端:把状态照原样显示出来,而不是去猜。

在外部权威之下,这些都不适用。 这三种机制都是为我们的模拟所运行的那些 Rooms 存在的。当一家工作室的游戏服务器或者一个 master-client 拥有这个 Tick 时,预测就是跑它的那一方的事——参见 Rooms 里的“谁在跑 Tick”。

这个模块不拥有任何属于自己的运动模型、反应表、几何和历史窗口。 它们分别属于 Locomotion、Collision、Map 和 Entity。Prediction 只是把它们提前施加、或者倒着读回来;它从不声明第二份副本。

Prediction declared on Tank: predicted fields, Hp authoritative-only, an 8-forward / 64-rewind window
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}
@Entity('tank')
@Prediction({ forwardTicks: 8, maxRewindTicks: 64 })
export class Tank {
  @Sync() @Predicted() position!: Vector3;                    // rolls back and replays
  @Sync() @Predicted() velocity!: Vector3;

  @Stat({ max: 100 }) @AuthoritativeOnly() hp: Stat;   // never predicted
}
@entity("tank")
@prediction(forward_ticks=8, max_rewind_ticks=64)
class Tank:
    position: Vector3 = sync(predicted=True)                  # rolls back and replays
    velocity: Vector3 = sync(predicted=True)

    hp = stat(max=100, authoritative_only=True)   # never predicted
Coming soon — Go

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

UCLASS(PSEntity = "tank", PSPrediction = (ForwardTicks = 8, MaxRewindTicks = 64))
class UTank : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Position;   // rolls back and replays
    UPROPERTY(PSSync = (Predicted = "true")) FVector3f Velocity;

    UPROPERTY(PSStat = (Max = 100, AuthoritativeOnly = "true")) FPSStat Hp;   // never predicted
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("tank")]
[Prediction(ForwardTicks = 8, MaxRewindTicks = 64)]
public class Tank
{
    [Sync, Predicted] public Vector3 Position;                        // rolls back and replays
    [Sync, Predicted] public Vector3 Velocity;

    [Stat(Max = 100), AuthoritativeOnly] public Stat Hp;   // never predicted
}

带延迟补偿的解算回答的是“这一枪开出去的时候大家都在哪儿”:

Hit validation in one hook: ResolveAt(shooterViewTick) rewinds hitboxes to the shooter's view
[After(Projectiles.HitReported)]
public static void Validate(HitReport hit) =>
    hit.ResolveAt(hit.ShooterViewTick);    // rewinds hitboxes, sub-tick interpolated
export const validate = after(Projectiles.hitReported, (hit: HitReport) =>
  hit.resolveAt(hit.shooterViewTick));    // rewinds hitboxes, sub-tick interpolated
@after(projectiles.hit_reported)
def validate(hit: HitReport):
    hit.resolve_at(hit.shooter_view_tick)    # rewinds hitboxes, sub-tick interpolated
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

弹道预报,由服务器和客户端共享(瞄准弧线、落点标记)。这个预报是本模块的一个 operation,是对着 Map 的障碍物集合外推出来的,所以两边从同样的输入画出同一条弧线:

One Trajectory call: a collision-aware forecast the server and the aim preview share
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware
const arc = room.prediction.trajectory(from, velocity, { steps: 30 });   // collision-aware
arc = room.prediction.trajectory(origin, velocity, steps=30)   # collision-aware
Coming soon — Go

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

// collision-aware: the arc the platform itself would walk
Room->Prediction->Trajectories->Get(LaunchPosition, LaunchVelocity, /*Steps*/ 30,
    TPSOnResult<FPSTrajectory>::CreateWeakLambda(this, [this](const TPSResult<FPSTrajectory>& Result)
    {
        if (!Result.HasValue()) { return; }
        DrawArc(Result.Value());
    }));
// 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
var arc = room.Prediction.Trajectory(from, velocity, steps: 30);   // collision-aware

模型

可预测性是声明在一个切面上的——而一个只由权威、按客户端并不拥有的规则来改动的切面,不可以被标记为可预测:那是部署时的一次校验失败,不是运行时的一个意外。

声明了什么。

声明是什么
predictable aspects客户端可以抢在权威之前推进哪些
divergence threshold低于它,一次纠正被平滑掉;高于它,权威的状态照单全收。这是声明出来的,而且是 managed——不是设计师每天要拧的旋钮
display mode for remote entities在已到达状态之间插值,或者外推
interpolation delay别人的显示落后多少,是声明出来的,而不是凭手感调的
extrapolation window超出它,一个 Entity 就被标记为陈旧,外推停止
compensation window一次回溯最远能回到多久以前,而且它是 managed
what is rewound目标的位置和朝向,以及在被声明为带历史时,动态障碍物的几何

什么会被回溯,以及什么被有意地不回溯。

是什么
rewound目标的位置和朝向,以及在类型把它们声明为带历史时,动态障碍物的几何
not rewound生命状态——死者不会为了挨枪而复活——以及归属、分数和背包
the rule behind the split决定是在过去作出的;而效果施加在当下
总是成立是什么
authoritative state names the input it saw它携带最后一个被应用输入的编号,正是这一点让和解变得精确而不是近似
divergence是可观察的:客户端知道它的预测被纠正过,而不是默默地漂走
a view time是一项主张,不是事实:这个 Actor 说它当时看到的那一刻。超出窗口,平台会拒绝,而不是外推:悄无声息的外推是送给作弊者的礼物,他只要发一个更老的时间就行
a rewind promises no reproducibility over floating point跟这份契约里其他每一处一样的约束
one history ring, two consumers和解与带延迟补偿的查询,读的都是 Entity 的瞬时历史轨道。回溯属于这里,而不属于被回溯的那些模块:这个环恢复出争议 tick 时的那些姿态,然后 Collision 再拿那些姿态去问它平常那个重叠判定问题——它自己不保留任何历史,也完全不知道什么叫“视野 tick”
客户端 tick:  在本地施加输入(预测)→ 存进缓冲 → 发出去,带 tick 戳
服务器 tick:  推进同一个运动模型 → 权威状态 → 发出 Delta
客户端收到:   tick T 的权威状态 → 如果分歧超出容差:
               回滚到 T → 重放缓冲里的输入 T+1..现在 → 平滑

只有被声明为可预测的字段才会回滚;细微的漂移被平滑掉,真正的分歧会回滚并重放。

错误

  • 一个超出窗口的视角时间是一次冲突:发一个当前的过来。改成外推,等于把整套机制拱手交给作弊者。
  • 一次超出窗口的历史请求同样是一次冲突。
  • 有两个声明缺陷在部署时被抓住:一个大于历史缓冲区的补偿窗口,以及一个在客户端没有规则可依时被标为可预测的切面。两者都到不了一场在线对局。
  • 有两样东西不是错误,而且两者都是可观察的。 一个满了的输入缓冲区会把预测挂起直到确认到来,而不是悄无声息地丢弃输入;而超过阈值的分歧意味着权威的状态照单全收,那就是那次已声明的纠正,不是一次故障。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 补偿窗口——超出它是一次拒绝,绝不是外推。
  • 对别人的外推窗口——这个 Entity 被标记为陈旧,外推停止。
  • 未确认输入缓冲区——预测被挂起直到确认到来;输入绝不会被悄无声息地丢弃。
  • 供回溯用的历史深度——不小于补偿窗口,而这在部署时会被检查。
  • 携带视角时间的动作速率——一次带期限的限流。
  • 每个客户端同时被预测的 Entities 数——超过上限就不做预测,而那是已声明的降级,不是一次拒绝。

用户流程

一枪在延迟之下打出去,在射击者的时间线里被判为公平,并在两块屏幕上都得到确认。那发 projectile 和受害者的属性块都是 Entity Presets——是 Entities 上的 Declarations,不是你要挂载的模块。

One shot under latency: the view tick is a claim, the rewind reads the entity’s own history ring, collision answers its ordinary question about those poses, and the effect lands in the present
The live game

预测你自己的移动

这是客户端自己的机器,也是三种预测机制里唯一一个犯错很便宜的。 你在服务器还没答复之前就按你自己的输入行动,服务器答复了,而在两者不一致的地方,你的客户端纠正自己。这里犯一次错的代价是一次小小的视觉纠正——这也正是为什么在这件事上激进是安全的。

预测是把已声明的规则再跑一遍,不是第二份副本

你的客户端并不跑一份你那套移动的平行实现。它跑的是同一个已声明的模型,也就是平台跑的那个——模型属于 Locomotion,而预测只是把它提前施加。这就是两边大多数时候能对上的全部原因:只有一套规则,被施加了两次。

这也意味着没有什么“预测”operation 可以调用,也没有什么“纠正”operation。预测之所以发生,是因为那个切面被声明为可预测,而不是因为你调用了什么东西。

什么会预测是按切面声明的

可预测性是 Entity 切面上的一份 Declaration,而它被有意地不做成一个全局开关:

  • 一个客户端算得出来的切面——你自己输入之下的位置——可以被预测。
  • 一个权威按客户端并不拥有的规则去改动的切面绝不可以被预测。如果客户端推导不出它,那么去猜它就会产生一次回滚,而玩家会把那读成游戏在撒谎。

那条线就是你决定“什么可以闪烁、什么必须第一次就对”的地方。

纠正协议,以及塑造它的那两个数字

Your input applied at once locally and the same declared rules run later on the server: where they agree you never knew, where they disagree only your client is corrected

权威状态到达时携带着它所应用的最后一个输入的编号,所以你的客户端确切地知道自己缓冲区里还有多少是未确认的。从那里开始:

  1. 接受这份权威状态。
  2. 重放它所确认的那个输入之后缓冲下来的那些输入。
  3. 把结果与你已经在显示的内容和解起来。

有两个已声明的数字决定了这件事的手感。分歧阈值:低于它,这次纠正被平滑掉;高于它,你的客户端就会拽一下并重放。以及未确认输入缓冲区的上界:溢出不是未定义行为——降级是声明出来且可观察的,所以一个网络糟糕的客户端知道自己已经停止预测了,而不是默默地漂走。

分歧对发生了它的那个客户端是可观察的,而且只对那个客户端。 你可以知道自己的预测被纠正过,以及被纠正了多少——这对调参有用,对给玩家显示一个诚实的连接指示器也有用。你读不到别人的分歧:一次错误预测的大小是关于他们连接状况的信息,不是关于这个游戏的。纠正是客户端一侧的事,因为权威的状态本来就是其他所有人一直在看的东西。

如果你是从别的地方过来的

  • Unreal 的 Mover 2.0。 这个形状很眼熟:带 tick 戳的输入、一个运动模型、来自权威的纠正。区别在于模型住在哪里——在这里你声明它,然后由平台去模拟它,所以没有一个属于我们的移动组件供你去子类化或者替换。
  • 回滚重放式网络代码,比如 Photon Fusion 里的。 在一次纠正之后重放你自己那些未确认的输入,是同一套机制,而且在这里是完整的。被有意排除在外的,是事后把整个世界再跑一遍——发生的是什么、以及为什么,参见延迟补偿。

本页不涵盖什么

其他玩家的 Entities 不是被预测出来的,而是被显示出来的——那是显示其他玩家。在射击者的时间线里判定一枪是一个服务器机制,住在延迟补偿。而当一个 Room 的权威模式是外部时,这三者一个都不适用:那时这个 Tick 属于跑它的那一方,预测也一样。

The live game

显示其他玩家

没有人预测其他玩家——他们是被显示出来的。 你按间隔收到他们的状态,而中间那段你得画点什么出来。这里犯错不会让任何人丢命;它的代价是一次看得见的抖动,这也正是为什么它有自己的 Declarations,而不是跟预测共用。

显示模式是声明出来的,不是猜出来的

对于不属于你的那些 Entities,这个 Room 会声明怎么填补已到达状态之间的空档:在你手上已有的状态之间插值,或者越过最新的那个往外推。那是 Entity 上的一份 Declaration,所以每个客户端上的答案都一样,不会随着渲染器是谁实现的而跑偏。

插值延迟同样是声明出来的。 要平滑地显示其他玩家,就意味着把他们显示得稍微迟一点,迟一个已声明的量。把这个数字点出来正是重点:一个没说清楚的延迟是一份你没法复现的 bug 报告,而一个说清楚的延迟是一个你可以照着自家品类去调的设计决定。

外推会停下,而不是编造

Their state arrives at intervals; between two arrivals you draw the gap yourself, and when the next does not come the motion stops rather than being invented

外推窗口是声明出来的,越过它,这个 Entity 就不再被显示为在移动,而不是靠猜继续往前走。无限外推会让玩家去打一个从来就不在那儿的目标,而玩家察觉不到——一次看得见的卡顿,才是能走出来的那种失败。

为什么这件事跟预测你自己的分开

三种预测机制有不同的权威和不同的失效模式,而三者共用一个词,意味着调其中一个会悄悄改掉另外两个。

机制跑在哪里它出错时
预测你自己的移动客户端一次纠正,重放并平滑
显示其他玩家客户端一次看得见的抖动
延迟补偿服务器有人死得不公平

这也正是为什么存在一个只带这一种机制、别的都不带的 observer preset:一名观战者没有自己的输入可以预测,所以给它配预测设置,等于是在配置一件它并不做的事。

The live game

延迟补偿

这是服务器的机制,也是三者里唯一一个犯错会要人命的。 当它判错一次,就会有一名玩家死得不公平——而且是偏袒了连接更差的那一方。本页上的一切都被这种不对称塑造着。

它回答的问题很窄:射击者当时到底看到了什么? 一个动作可以携带一个视角时间,也就是这个 Actor 行动时所看着的那个 tick,而平台会把目标在那个 tick 时的姿态恢复出来,好让这一枪按他们屏幕上当时的样子来判定。

视角时间是一项主张,不是事实

它来自客户端,所以它是调用方的一项断言,也被当作断言来对待。有两个后果:

  • 补偿窗口是有界的,而在它之外平台会拒绝。 它不会好心地去外推。一次拒绝是一个你看得见的决定;一次悄无声息的外推是一个你看不见的决定。
  • 读取一个目标过去的状态仍然遵守可见性。 询问一个历史 tick 不是绕开 Visibility 的办法——你当时看不到的,现在也读不到。

而“这一枪不算”是一个裁定,不是一个错误:一次成功的答复,带着机器可读的理由。你的代码问了一个合法的问题,得到了一个合法的“不”。

什么会回滚是声明出来的,而且不是全部

把所有东西都回滚听起来很一致,实际会产出双杀:两名玩家互相开枪,两人都被回溯到一个双方都还活着的时刻,两人都命中。而什么都不回滚,则等于取消了延迟补偿本身。两者之间的边界是一份已声明的清单,不是某个实现的直觉。

回溯本身属于这里,而不属于被回溯的那些模块。历史环恢复出争议 tick 时的那些姿态,然后再拿那些姿态去问 Collision 它平常那个重叠判定问题——碰撞自己不保留任何历史,它里面也没有任何东西知道什么叫视角 tick。而这个环本身就是 Entity 的历史轨道,不是第二个存储。

判定发生在过去;效果施加在当下

The shot judged by rewinding the declared state to the tick the shooter saw, with the effect applied in the present

延迟补偿回答的是一个关于射击者视角那一刻的问题。而那些后果——伤害、死亡、奖励——施加在当前的状态上。视角那一刻和决定那一刻之间发生的事,既不会被取消,也不会被重算。

所以下面这件事是可观察的,而且是有意为之:一名玩家可以在已经被别人一发经过回溯判定的子弹打死之后,仍然把一枪打出去。取消这一点,就意味着要在一次并不承诺可重现性的回溯之上重放整个世界——那是在制造分歧,而不是在消除分歧。

服务端一侧的重新模拟被有意排除在范围之外。 要按一份新的真相去重算后果,需要一个固定的参照点,而浮点状态给不了我们。留下来的是这个模块所倚靠的一切:一个客户端重放它自己那些未确认的输入(预测你自己的移动),以及作为为一个决定去读过去的延迟补偿。实践中的“偏袒射击者”就是这么运作起来的。

如果你是从别的地方过来的

  • 偏袒射击者的延迟补偿,就像大多数竞技射击游戏里交付的那样:同一套机制,而本页就是它。
  • 完整的回滚式网络同步。 回溯在这里;而之后对整个世界的重放不在,上面那一段解释了原因。如果你的设计依赖于后果在事后被重算,那这条依赖是要早点跟我们提出来的东西,而不是到晚了才发现。

另外值得知道的

  • 实现是可覆盖的。 如果你的游戏需要一条不同的补偿规则,你可以替换掉我们的,而这个替代实现会声明它遵守哪些 Declarations。
  • 在预测和纠正这条路径上没有任何扩展点。 那些东西按 tick 速率运行,而那个循环里的一个 Hook 会是你负担不起的 Hook。
  • 在外部权威之下这些都不适用。 延迟补偿是为我们的模拟所运行的那些 Rooms 存在的。当这个 Tick 属于一家工作室的游戏服务器或者一个 master-client 时,补偿就属于跑它的那一方——参见 Rooms 里的“谁在跑 Tick”。
The live game

Bots

一个 bot 作为一名普通玩家加入。只有大脑住在别处。 同样的会话、同样的入场校验、同样的规则、同样的 ACL。这个 Room 分辨不出差别,而这是设计使然,所以 bots 会真正跑一遍你的游戏规则,而反作弊永远不需要一条 bot 例外。

Who addresses Bots, the 4 things it provides, and the 3 modules it builds on
A bot and a human as the same kind of participant in the room, differing only in where the decisions are made

何时使用

  • 你的大厅在非高峰时段需要填人——FillRoom 把对局补到一个配额,而随着真人到来,bots 会把座位让出来。
  • Bots 必须按真实规则来玩——入场校验、ACL、Visibility——这样反作弊永远不需要一条 bot 例外。
  • 你带来一个外部大脑——一个学出来的策略、一个服务——它像任何玩家一样通过 ConnectAsBot 加入。
  • 一名掉线玩家的 Entity 必须交给一个 bot,并在重连时交回来,而座位和 Prediction & Lag Comp 都察觉不到。
  • 不必用它:这个角色从不作决定时——一个没有大脑的对话 NPC 住在 World Objects。

本页是“接进来”的那一半。 把一个 bot 弄进一个 Room、把一个大厅补到配额、在一个 bot 和一个真人之间交接一个座位。写那个作决定的东西是另一半——写一个大脑,它规定了一个大脑插进去的那个插座。

谁做什么

Actor在本页
bot-brain作为一名玩家连接;接收感知;发出命令
room-owner声明档案,把 Rooms 补到配额,做 bot/真人的交接

一览

The filler profile: honest difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

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

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// 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 — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

一个外部大脑(更重的 AI、一个学出来的策略、一个服务)像任何玩家一样连接:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

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

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// 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
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

模型

一个 bot 不引入任何属于它自己的概念——不是一种参与者、不是一条输入通道、不是一个可见性区域、也不是一种行为。它是一份 Actor 凭据,穿着 Rooms、Data & Subscriptions 和 Locomotion 已经声明好的那些东西。

一个 bot 的 Declaration 携带什么。

声明是什么
thinking tick多久去问一次那些大脑,而它不是模拟 tick:那些大脑跑在外面,而每个 tick 发一次网络调用是不可行的
direction of the brains它们在哪里执行——一个云函数、这家工作室的后端、它的游戏服务器。是哪一个不属于契约的一部分,而在它们之间挪动不是一次破坏性变更
actor preset这个 bot 的权利,就是一个普通的 Actor preset
visibility of the bot marker参与者会不会被告知。这个标记本身始终存在,而平台始终观察着它;至于玩家看不看得到它,是这个 Room 类型的 Declaration,因为在有些市场披露一个 AI 对手是一项义务,而在另一些市场它是一个产品选择
behaviour when the brains are unavailable三者之一,没有默认值:do nothing、leave the room、fall back to built-in default behaviour
roster filling由 Room 类型来声明——补几个、在什么条件下、补到哪一刻为止。Matchmaking 对 bots 一无所知:它撮合的是 Actors,而不决定拿谁来补人

每一个 bot 都成立的事。

总是成立是什么
an actor, not a player它持有一份 Actor 凭据,但没有登录提供方、没有关联、也没有会话
economic ownership一个都没有——没有权益、没有购买、没有 Leaderboard 条目——否则 bots 最后会跑到排名里和经济系统里去
perception就是一名玩家的:同样的可见性区域、同样的谓词、同样的物体数量上限。同一个位置上的一个 bot 和一名玩家收到的是同一组物体,所以一个 bot 透视墙壁的本事并不比一名玩家多
wider perception是一个 Actor preset,不是 bot 的一项属性:一个调试模式或者“全知教练”模式,是作为一个带更宽谓词的 preset 声明出来的
between thoughts最后一个命令继续生效,而它的下场就是这种运动类型对过期输入已经声明好的那一套——一个大脑正在思考的 bot,跟一名断了网的玩家是同一种情况
room capacity把 bot 算进去:它跟别人一样占一个座位

错误

  • 这个 Room 不接受 bots 是一次冲突,重复也没用。
  • bot 上限用尽是一次冲突,不是 forbidden——引入一个的权限是有的;是这个 Room 满了。等 Room 腾出空间之后值得再来一次。
  • 一个没有相应权限的 Actor 发出的针对某个 bot 的命令回答 forbidden,而重复没有意义。
  • 那些大脑不可用不是一个错误——它是上面那三种已声明行为之一。它们是慢了、挂了还是在想,是执行它们的那个方向自己的事,不属于契约的一部分;可观察的东西,就是对任何参与者而言可观察的那些。
  • 在部署时声明,在部署时拒绝:一个被点名为某条 Leaderboard 记录所有者的 bot,以及一个缺失的思考 tick,都是部署时的校验失败,而不是一个在线 Room 里的意外。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 一个 Room 里的 bots 数——引入作为冲突被拒绝;已有的 bots 绝不会被移除来腾地方。
  • 每个 Project 的 bots 数——同样的冲突。
  • 思考 tick 的下限——一份比下限还快的 Declaration 会在部署时被拒绝,因为每个 tick 一次网络调用是不可行的。
  • 针对一个 bot 的命令速率——一次带时间的限流。
  • 等待大脑响应的期限——一旦它过期,那条已声明的不可用行为就生效。

用户流程

一台主机把大厅补到配额,一个外部大脑占了其中一个座位,而这个 Room 全程都按真实规则运行。

A lobby topped to quota and an external brain in one of the seats: the brain receives what a player in that seat would receive, sends what a player would send, and yields when a human arrives
The live game

写一个大脑

一个大脑就是回答一个问题的普通代码:这个 bot 接下来做什么。 它可以跑在你想让它跑的任何地方——一个云函数、你自己的服务、一个无界面客户端——而它跟这个 Room 说话所用的接口面,跟一名真人玩家的客户端用的是同一套。本页规定了它插进去的那个插座:一个大脑接收什么、可以发回什么、以及在什么时候。Bots 讲的是另一半:把一个 bot 弄进一个 Room。

什么已经定了,以及你今天就能照着建什么

平台不交付任何游戏 AI。 没有行为树,没有效用系统,没有导航大脑。那不是一个等着被填上的缺口——那就是边界。决定是你的,而这个模块的活儿,是让你的决定跟一名玩家的无从分辨。

一个大脑不是一个 Hook。 一个 Hook 包裹的是我们的某一步。而一个大脑根本就不是我们的某一步:它跑在这个 Room 之外,按它自己的节奏,而平台不在乎这个连接是从哪个方向打开的。这就是为什么一个大脑可以是一个云函数、一个你托管的服务,或者一个无界面客户端——也是为什么这三者里没有哪一个比别的更“原生”。

这个插座就是感知进来、命令出去,而两侧都被有意地做成玩家的那一套:

是什么
感知恰好就是坐在那个座位上的一名玩家会收到的东西——同样的 Deltas,经由同样的 Visibility 规则。一个 bot 透视墙壁的本事并不比一名玩家多。
命令恰好就是坐在那个座位上的一名玩家会发出的东西。不存在任何有特权的输入通道。

如果一个游戏确实需要一个看得更多的 bot——一个调试模式、一个训练模式——那是一次已声明的放宽,而不是“身为 bot”的副作用。

思考 tick 是声明出来的,而它不是模拟 tick。 那些大脑在外面,所以它们按自己的节奏思考。在两次思考之间,最后一个命令继续生效——这是设计时最需要围绕来考虑的一件事,因为它意味着一个思考很慢的大脑,产出的不是一个杵在原地的 bot,而是一个继续做它上次决定那件事的 bot。

大脑消失有已声明的行为,而且没有默认值。 你按 Room 类型说明当大脑停止回答时会发生什么。“大脑不可用”是一个 retained Event,所以一个晚到的订阅者了解到的是当前的情况,而不只是将来的变化。

一个 bot 被有意地不能是什么

在你围绕它做设计之前值得读一读,因为这些是拒绝,而不是遗漏。

  • 一个 bot 不是一名玩家,它不拥有权益、购买或者 Leaderboard 记录。一个能持有这些东西的 bot,会变成一条制造它们的路子。
  • bot 标记始终存在,而且对平台始终可观察。你的游戏要不要把它展示给玩家,是你的决定;它存不存在,不是。
  • 这个模块不保存任何关于一个 bot 决定了什么、为什么这么决定的历史。 那是你的事,在你的遥测里——我们不会变成你的 AI 推理过程的存放地。
Services around the game

Auth & Players

登录是一个可覆盖的步骤,不是一个黑盒。 提供方、会话、身份关联、封禁。这个流程里的每一个点——登录前和登录后、关联前和关联后、合并前和合并后、状态变更时——都是一个带已声明种类的已声明扩展点:一道可以拒绝这一步的门禁,或者一个拒绝不了的观察者。

Who addresses Auth, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 玩家必须登录——设备、邮箱、Apple、Google、Steam 或者自定义——而“首次登录即创建”是一个开关,不是第二条流程。
  • 一个访客账号之后必须能升级——Link 把 Steam 加上去而进度分毫不失,而合并把两个账号调和成一名玩家。
  • 策略必须跑在跳不过去的地方——一道登录前的区域门禁,一份在“创建了这名玩家的那次登录”之后发的新手礼包。
  • 审核必须有牙齿——撤销会话、封停、设备封禁,外加一个每个在线系统同时都能听到的 banned Event。
  • 已声明的上下文(区域、平台、构建)必须到达后面每一个 Hook,而不必每一个都重新去读一遍那名玩家才知道。
  • 没有更轻的东西可以退而求其次——其他每一个模块都是通过这一个来点名它的调用方的,而只要其中任何一个还需要一个玩家 Actor,auth 就关不掉:模块配置器会拒绝,并点名那些依赖方。

谁做什么

Actor在本页
player登录,关联或解绑身份,刷新,登出
moderator撤销会话;封禁、封停或恢复玩家
backend-service按区域把关登录;为一名新玩家播下最初的那些行;读取并撤销会话

一览

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

每一个点都在它被声明的地方定制;一个处理函数可以采取的那些形式收在 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.

被声明出来的那个形式,就是面板会渲染的东西:每一个点都显示它的处理函数、它们的种类,以及解析后的顺序。种类是有牙齿的那一部分:

种类当处理函数自己失败时拒绝时
一道门禁这一步被拒绝——一次到不了的区域检查不是一次通过了的区域检查一个来自平台目录的代码,外加一条给人看的原因。调用方按代码分支;那段原因文本可以随意改动,也可以被翻译
一个观察者这一步保持已完成,所以一份没能发下去的新手礼包,代价是一个箱子,而不是那次登录它无法拒绝

任何处理函数都不可以做的一件事,是决定是谁登录了。一道门禁只针对平台已经确立好的身份回答是或否;它不点名那名玩家,不发放身份,也不替代提供方的确认。那条线就是“可覆盖的登录”和“可跳过的登录”之间的区别。

模型

Several sign-in identities linked to one player, with sign in, link and merge as declared steps you can replace

一名玩家是身份的承载者,不是你 schema 里的一行,而他们的标识符是稳定且绝不复用的——合并时也一样:一名被合并玩家的 id 仍然解析得出来,而不会变成一个悬空引用。一次关联是一个三元组:提供方、外部主体、玩家。

总是成立是什么
provider + subject是唯一的,而这种唯一性是一次冲突的来源,不是一条禁令——“这个账号已经被占了”的答案是去选择一次合并,而不是被告知不行
at most one link per provider per player同一个提供方的第二个账号是一次冲突
an external subject绝不是一名玩家的标识符:它属于那个提供方,而把它当成我们的,就会把我们的 id 绑到他们的上面去
identity kind and access status是两条不同的轴:anonymous 对 registered 是一条;active / suspended / banned 是另一条。把它们混为一谈,就没法表达“被封禁的匿名用户”或者“被封停的注册用户”
a session and a credential是两样东西:一个会话是那条记录;一份凭据是你出示的东西。撤销一个会话会让它所有的凭据失效,并关掉它那些开着的订阅
many simultaneous sessions每一个都可以独立撤销
a credential's claims是已声明的上下文——区域、语言环境——而且只是上下文。一个 claim 绝不携带权威
a device fingerprint不是一个身份:它绝不是准入的依据,只是拒绝的依据,而且它是以不可逆的形式存储和比对的

三台状态机。

属于状态
身份的种类anonymous → registered,而这次转移是单向的
访问状态active ⇄ suspended,以及解封时的 active → banned → active
玩家alive → merged,其中 merged 是终态:一名被合并的玩家不会再登录

消费方声明什么。

声明是什么
sign-in policy是否允许匿名登录,以及围绕登录的其余规则。作为模块挂载点上的一个特性声明出来——不是代码旁边的一个配置文件,也不是在运行时构造出来的
default role一名新玩家在首次登录时携带的那一组。默认值本身没有默认值:什么都不声明,新玩家就带着零个角色到来,而那是一份合法的 Declaration,不是一次遗漏
session policy达到并发会话上限时会发生什么——带一个 Event 把最老的挤出去,或者拒绝新的那个。没有默认值
deletion policy一名玩家的删除如何抵达那些引用他们的数据

一个提供方在哪里配置。 在运营平面里,不在代码里——一份商店凭据不该待在一个代码仓库里。被声明出来的东西会抵达管理控制台供阅读。

授予一个角色会做什么。 角色不只是 operator 的事:这套接口面为一名玩家带上了 grant 和 revoke,所以一个游戏可以从它自己的代码里提拔一名公会干事,或者把权限交给一位赛事主持人。

总是成立是什么
it is not self-promotion授予需要相应的已声明权限 atom,而一个没有那个 atom 的 Actor 会得到一个干脆的 forbidden,而不是一次悄无声息的空操作
granting is idempotent授予一个玩家已经持有的角色是一次成功,不是一次冲突:状态是那组角色,而不是调用的历史,所以跟登录不同,这个 operation 不需要幂等键
revoking is not instant而且我们不假装它是。它无需重新签发凭据就会生效,而且最迟会在权利缓存所声明的陈旧上界处变得可观察——所以那种“授予一个角色然后立刻在一个已连接客户端上去检查它”的代码,必须围绕那个窗口来设计
the default role是按 Project 声明的:一名新玩家在首次登录时携带的那一组。默认值本身没有默认值——什么都不声明,新玩家就带着零个角色到来,而那是一份合法的 Declaration,不是一次遗漏

角色由什么构成、它解锁什么,都在 Access & Roles。

错误

  • 没有凭据,或者凭据过期了,回答 not authenticated,而一次刷新就能解决。一份被撤销的凭据回答同样的东西,但刷新解决不了:只有重新登录。
  • 一份被轮换过的凭据再次被出示是一次冲突——正是这一点让轮换变得可检测,而不是被默默容忍。
  • 一名被封禁或被封停的玩家,以及一个被封禁的指纹,都回答 forbidden,而重复没有意义。
  • 提供方+主体这一对已被占用是一次冲突,在选择了合并之后可以再来;而同一个提供方的第二个账号是一次重试也改变不了的冲突。
  • 解绑最后一种登录方式是一次校验拒绝:那会留下一个谁也够不着的账号。
  • 合并一名已经被合并的玩家是一次冲突——merged 是终态。
  • 提供方不可用回答 unavailable,值得带退避地重试;而提供方拒绝了那份凭据回答 not authenticated,值得重试一次,而不是循环重试。把这两者压成一个,会让客户端去锤一个已经说了不行的提供方。
  • 登录尝试速率超了在限流类别下作答,带一个期限。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 每名玩家的并发会话数——按已声明的策略来:带一个 Event 把最老的挤出去,或者拒绝新的那个。没有默认值。
  • 每个时段的登录尝试数,以及关联一对已被占用组合的尝试数——一次带期限的限流,而尝试计数留在历史里。
  • 每名玩家的关联数——再关联一个提供方作为冲突被拒绝。
  • 一份凭据的寿命——not authenticated,可以靠一次刷新再来。一份刷新凭据的寿命——只有重新登录。
  • 一名从未登录过的匿名玩家的保留期——按已声明的策略删除,带一个 Event。这条策略是显式声明的;没有默认值。
  • 指纹封禁名单里的条目数——添加会被拒绝,而旧条目绝不会被悄无声息地挤出去。

用户流程

首次启动时的一个访客账号,之后升级到 Steam 而进度分毫不失。

A guest on first launch and the same player after linking Steam: one identifier throughout, with the seeding hook running once, on the sign-in that created them
Services around the game

Profile

一个 Profile 是一个视图,而平台几乎不拥有它的任何部分。 平台关于一名玩家所保存的,是 player_id 和它背后的系统档案——身份、会话、提供方关联,这些全都在 Auth & Players。一名玩家所拥有的一切,都是你自己的 Entity,归那名玩家所有。一个 Profile 就是你的 Project 声明的那一组 Entities,为一个所有者一趟读出来。

Who addresses Profile, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 一个界面需要一次调用就拿到某名玩家的那一片——已声明的这一组会扇出到他们拥有的那些 Entities 上,而不是让客户端把好几个查询缝起来。
  • 平台的各个接口面必须显示一个人,而不是一个标识符——一块 Leaderboard、一个审核队列和一张支持工单,在这个 Project 点名“哪条记录用来展示一名玩家”之前,手里只有一个 player_id,别的什么都没有。
  • 另一名玩家需要一张卡片——同一次读取,针对另一个所有者,被 Access & Roles 里已经声明好的行谓词和列掩码收窄。
  • 一个 HUD 必须实时跟踪自有状态——这次读取是一个选择集,而选择集可以订阅。
  • 不必用它:这份数据不归某名玩家所有时——共享的和全局的行是一次普通的 Entity 选择,没有所有者可供扇出。

谁做什么

Actor在本页
schema-author把 Entities 标记为玩家自有,并声明其中哪些构成这个 Profile
player读取他们自己的 Profile;写入去到那些 Entities 本身
room-visitor读取另一名玩家的 Profile,读到那名玩家的谓词和掩码所允许的程度为止

一览

归属是按 Entity 声明的,不是按字段。是这个 Entity 说自己属于这个 Profile;而另一名玩家能看到它多少,取决于读取它的那个角色上的列掩码(Access & Roles)。一个字段级的视图特性,会是对访问机制已经回答过的问题的第二个答案,而这两者会在有人编辑其中一个的那一刻开始跑偏。

loadout and progress marked player-owned and put in the profile set
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}
@Entity('loadout') @OwnedBy(Owner.player) @InProfile()
export class Loadout { primary = ''; }

@Entity('progress') @OwnedBy(Owner.player) @InProfile()
export class Progress {
  level = 0;
  title = '';
  secretMmr = 0;                          // no reading role's mask names it: it stays server-side
}
@entity("loadout")
@owned_by(Owner.PLAYER)
@in_profile
class Loadout:
    primary: str = ""

@entity("progress")
@owned_by(Owner.PLAYER)
@in_profile
class Progress:
    level: int = 0
    title: str = ""
    secret_mmr: int = 0                   # no reading role's mask names it: it stays server-side
Coming soon — Go

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

UCLASS(PSEntity = "loadout", PSOwnedBy = "Player", PSInProfile)
class ULoadout : public UObject
{
    GENERATED_BODY()

    UPROPERTY() FString Primary;
};

UCLASS(PSEntity = "progress", PSOwnedBy = "Player", PSInProfile)
class UProgress : public UObject
{
    GENERATED_BODY()

    UPROPERTY() int32   Level;
    UPROPERTY() FString Title;
    UPROPERTY() int32   SecretMmr;        // no reading role's mask names it: it stays server-side
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same attributes compile in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Entity("loadout"), OwnedBy(Owner.Player), InProfile]
public class Loadout { public string Primary = ""; }

[Entity("progress"), OwnedBy(Owner.Player), InProfile]
public class Progress
{
    public int Level;
    public string Title = "";
    public int SecretMmr;                 // no reading role's mask names it: it stays server-side
}

这次读取是一个按所有者限定作用域的选择——就是 Entity 的查询接口面,只是所有者被定死了,而 Entity 清单取自那份 Declaration。Profile 是那次读取的名字,不是站在它背后的一个模块:同样的权利、同样的谓词、同样的过滤器、同样的订阅,因为它就是同一个 operation。

One pass over my own rows, live; then a rival's, as far as the mask allows
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves
const mine = playserv.profile.mine();                     // a selection, not a record
const rows = await mine.query();                          // loadout + progress, one pass
mine.subscribe((changed) => hud.refresh(changed));        // the selection stays live

const rival = await playserv.profile.of(rivalId).query(); // only what the mask leaves
mine = playserv.profile.mine()                            # a selection, not a record
rows = await mine.query()                                 # loadout + progress, one pass
mine.subscribe(lambda changed: hud.refresh(changed))      # the selection stays live

rival = await playserv.profile.of(rival_id).query()       # only what the mask leaves
Coming soon — Go

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

TPSSelection<UPSProfile> MyProfile = Client->Entities->Of<UPSProfile>()->Select().GetMine();   // a selection, not a record
MyProfile.Then(TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Result)
{
    if (!Result.HasValue()) { return; }
    Hud->ShowProfile(Result.Value());          // loadout + progress, one pass
}));
TPSSubscription ProfileWatch = MyProfile.Subscribe(
    [this](const FPSProfileChange& Changed) { Hud->Refresh(Changed); });

Client->Entities->Of<UPSProfile>()->Get(RivalId,
    TPSOnResult<FPSProfileRows>::CreateWeakLambda(this, [this](const TPSResult<FPSProfileRows>& Rival)
    {
        if (!Rival.HasValue()) { return; }
        Hud->ShowRival(Rival.Value());
    }));
// 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
var mine = playserv.Profile.Mine();                       // a selection, not a record
var rows = await mine.Query();                            // loadout + progress, one pass
mine.Subscribe(changed => Hud.Refresh(changed));          // the selection stays live

var rival = await playserv.Profile.Of(rivalId).Query();   // only what the mask leaves

模型

概念是什么
player_id平台关于一名玩家的全部想法,加上它背后的系统档案——Auth & Players
profile 集合这个 Project 声明为它 Profile 的那些玩家自有 Entities;声明它是可选的
所有者选择那次读取:进去一个所有者,出来他们横跨这一组的那些行——跟任何 Entity 选择一样的权利、谓词、过滤器和订阅
公开读取那次选择针对另一个所有者,被读取角色的行谓词和列掩码收窄(Access & Roles)

每一次 Profile 读取都成立的事。

总是成立是什么
there is no profile record它没有属于自己的标识符、没有 Revision、没有历史、也没有生命周期,因为它是一个覆盖在那些四样俱全的行之上的视图
writes go where the data lives去改 progress 那一行,而每一次包含它的 Profile 读取,在下一趟就会看到新值
there is no public write一个视图没有什么可写的,而可共享写入的状态走服务端代码
declaring the set is optional而不声明它跟声明一个空集不是一回事:一个没有 Profile 集合的 Project 根本就没有 Profile 读取,这次调用会以 unavailable 被拒绝;而一个空结果则会说这名玩家有一个 Profile,只是碰巧是空的
ownership is a predicate不是平台加上去的一列(Access & Roles):owner == caller.player 是这套机制的一个实例,“参与者之一”是另一个
a derived field belongs to a hook当 Level 越过一个阈值时,是一个变更后观察者去盖上 Progress.Title。它跟在那次写入后面;它拒绝不了那次写入

错误

  • 一个没有已声明 Profile 集合的 Project 没有 Profile 读取,这次调用以 unavailable 被拒绝——而不是答以一个空结果,那会说这名玩家有一个 Profile,只是碰巧是空的。
  • 一个被谓词藏起来的行回答 not found,一个不存在的行同样如此:一次公开读取绝不会变成一条“打听什么存在但看不到”的路子。
  • 读取角色掩码之外的字段在答案里是缺席的,而不是在场且为空。
  • 不存在公开写入。 一个视图没有什么可写的,所以可共享写入的状态走服务端代码,而不走这套接口面。
  • 一次代表某名玩家、却没有点名那名玩家的写入是一次校验拒绝。
  • 声明 Profile 集合是一次 schema 行为——fn 或 adm。一个玩家密钥去做这件事会被答以 forbidden,而这次 push 是整份被拒,而不是声明了一半。

限制

每一条上限都点名它在边界处的行为;数字会随平台限制那一章一起落地。

  • 所有者选择的页大小——裁到上限,而“还有更多”标记仍然为真;返回更少却不带那个标记,是被禁止的。
  • 每一行所含包含集合的大小——按同一条规则裁剪,标记打在那个包含项上。
  • 单个实例上的变更速率——一次带期限的限流拒绝;Profile 读取跟其他任何选择一样,继承 Entity 的那些上限,而不声明它自己的。

用户流程

从大厅里的涂装一直到等级跳一格:一次服务端的写入抵达一块已订阅的屏幕,而那块屏幕不用再问一次。

A server-side write reaching a subscribed screen: the profile read is a selection like any other, so the screen never asks again — the delta arrives on its own
Services around the game

Social

一个新概念,其余一切都是用你已经有的东西搭起来的。 两个 Actors 之间的一段关系,带一个属于它自己的状态和一个发起人——这就是这个模块添加的全部。一个氏族、一个公会或者一支小队,是一个覆上了一层关系的 Groups,不是第二种东西;而拉黑——好几个模块都需要它——住在这里,好让只有一个地方拥有它。

Who addresses Social, the 5 things it provides, and the 3 modules it builds on

何时使用

  • 玩家需要按名字找到彼此——好友、关注者、黑名单。
  • 一个氏族或公会需要一扇门——来自这个 Group 的邀请、来自一个 Actor 的加入申请,以及对二者之一的决定。
  • 一个好友列表必须显示谁在线——在场状态是从会话推导出来的,而谁能看到它是一个你声明的谓词。
  • 另一个模块需要知道某人被拉黑了——它从这里读那个状态,而不是自己另存一份。
  • 不必用它:这样东西是一组 Actors 而不是一对带状态的搭档——那是一个 Groups,而每一对一个 Group 意味着几百万个两人 Group,每一个都带自己的生命周期和进入规则。

谁做什么

Actor可以不可以
player提出一段关系或者关注;接受、拒绝或者撤回;断开一段互相的关系;拉黑和取消拉黑;读取他们自己的关系和相关 Actors 的在场状态;订阅变化;提交加入申请在任何参与者关系之下,读取别人的关系列表
moderator在他们持有成员关系管理 atom 的地方,对邀请和加入申请作决定对一个他们没有相应权限的意向作决定——那会回答 forbidden

模型

一份关系 Declaration 携带什么。

声明是什么
kind对称的——这一对需要双方都同意,而下面那台状态机说的就是它;或者单向的——关注,它唯一的状态是 active。每一对的唯一性和一次提议的幂等性,对两者都成立
re-invitation rule在一次拒绝之后:禁止、在一段已声明的时间之后允许、立刻允许。这是声明出来的,因为“再问一次”是一个产品决定
presence visibility一个谓词——对所有人、只对互相连接的人、对任何人都不。没有默认值
joining mode(在 Group 类型上)开放、申请后由人决定、仅限邀请
retention of declined and broken过了已声明的时段,这段关系被移除,而重新邀请无论重新邀请规则怎么写都重新变得可能

一段对称关系的状态。

状态含义
proposed发起人提出了,而另一方还没有回答
mutual双方都同意
declined另一方拒绝了。这段关系被保留下来,因为重新邀请规则需要知道
broken一方离开了一段互相的关系
blocked一方拉黑了另一方

每一段关系都成立的事。

总是成立是什么
one entity per pair不是两条互为镜像的记录。“A 向 B 提出了”和“B 被 A 提出了”是从两侧读到的同一个事实
an initiator是声明出来的:是谁提出的,展示和重新邀请规则都需要它
blocked dominates从它出发,没有通往 proposed 或 mutual 的转移
a block在控制上是不对称的,在效果上是对称的:只有设下它的那一方可以解除它,而它双向生效
a refusal on a block不会泄露它:这个 operation 回答 not found,所以一个被拉黑的 Actor 没法靠试探发现这次拉黑
the block state归这里所有,在别处被消费:Messaging 和其他模块会读它;它们没有一个去改动它,也没有一个留副本
presence是从会话推导出来的:没有任何人去写它,而可见性谓词是按请求者施加的,而不是按每个 Actor 施加一次
a deferred intent不占座位:一次邀请或者一次加入申请绝不会计入这个 Group 的容量——否则一百份申请就能耗尽一个五十人的氏族,谁也进不来了
a group保留着一个管理员:至少一个 Actor 必须持有成员关系管理 atom,而最后那一个不能就这么走人:一个最后一名管理员离开了的氏族,将再也没法接纳任何人
no intra-group roles“氏族干事”是一个持有某个 atom 的 Actor,不是存在某个列表里的一个级别
an import never overwrites从一个登录提供方带进来的关系是追加式的:一个被拉黑的人不会因为某个提供方这么说就变成好友

错误

  • 已经是互相的是一次冲突;没有什么可提出的了。
  • 向自己提出是一次校验拒绝。
  • 其中一方设了拉黑回答 not found——不是 forbidden,因为一次能把两者分开的拒绝会泄露这次拉黑。重复没有意义。
  • 在期限之前重新邀请是一次冲突,过了期限值得再来。
  • 某条限制用尽——关系、意向——是一次冲突,不是 forbidden:权限是有的,位置没有了。等有一个空出来,或者等已有的那些意向被决定之后再来一次。
  • 一个过期的意向是一次冲突:造一个新的,而不是重试那个旧的。
  • 一个 Group 最后一名管理员的离开在权限交接出去之前是一次冲突。
  • 在没有相应权限的情况下对别人的意向作决定回答 forbidden,而重复没有意义。
  • 从一个未连接的提供方导入回答 unavailable——带退避地重试。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 每个 Actor 的互相关系数——一次提议作为冲突被拒绝;已有的那些绝不会被断开来腾地方。
  • 每个 Actor 的单向关系数——新的那个被拒绝;已有的那些留着。
  • 发出去的提议数——新的那个被拒绝,而且不存在挤出:一个被挤掉的邀请会和一个被拒绝的邀请无从分辨。
  • 每个 Actor 的拉黑数——添加一个作为冲突被拒绝,而更旧的拉黑不会被挤掉;一个被悄无声息解除拉黑的人又开始发消息,而没人知道为什么。
  • 一个意向的寿命——expired,带一个 Event。
  • 每个 Actor 的提议速率——一次带时间的限流。
  • 流里在场状态变化的速率——靠更新速率来约束,而不是靠丢弃变化。
  • 被拒绝和已断开关系的保留期——按已声明的时段移除。

用户流程

A join request from a player, the moderator's decision, and the membership that follows in `groups`
Services around the game

Messaging

Rooms、Groups、玩家:聊天和通知共用一个寻址模型。 消息到达一场会话;而会话就是 Core 的 Channels,在上面加了历史、审核和带外投递。

Who addresses Messaging, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 玩家要说话——Room 聊天、公会频道、私信——用的是你本来就有的那套寻址:Room、Groups、玩家。
  • 离线玩家也必须听得到——模板化、可排程的通知通过推送做带外投递。
  • 审核必须跑在投递之前——一个发送前 Hook 过滤或者拒绝,而禁言/拉黑在任何地方都由平台强制执行。
  • 回归的玩家需要补课——History(take: 50) 在下次启动时把这场会话分页拉出来。
  • 不必用它:载荷是游戏状态而不是对话时——Data & Subscriptions 里的同步字段和 Core 的 Channels 已经把那些扇出去了。

谁做什么

Actor在本页
player发送和接收消息;读取历史;禁言或拉黑
moderator过滤、涂抹,以及封禁词条
backend-service发送或排程模板化通知

一览

One Send per addressing target — room, guild, direct — plus subscribe and history
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Coming soon — Go

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

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// 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
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

一条结构化消息是一个已声明的 Event,而这场会话随后按名字来承载它——调用点上没有什么载荷类要构造:

RallyCall declared once; the guild conversation sends it by name
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Coming soon — Go

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

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// 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 — the same declaration and call compile in Unity, on the 2021.3 baseline
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

那条已声明的消息在同一个订阅里带着类型到达,所以一个认识 RallyCall 的客户端拿到的是字段,而不是一坨二进制。

通知是带外的、模板化的、可排程的——而且它们是以 fn 或 adm 权威发出的,绝不从一个玩家会话发出:

A templated raid-starts notification, sent from a cloud function and delivered out-of-band
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.

A different actor calls this

The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.

审核以 Hooks 的形式,跟别处同一份契约:

A pre-send hook: profanity is rejected before it ever lands
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

模型

一个会话类型声明什么。

声明是什么
the group它的名册——一名参与者是一个 Actor,跟 Groups 里一模一样
binding to a lifetime可选地绑到另一个 Entity 的生命周期上,这样一个 Room 聊天会随它的 Room 一起消失

信封和载荷之间的那条线画在哪里。

部分归谁
envelope平台的:作者、会话,以及按已声明时钟的那一刻
payload工作室的,作为一个带类型化字段的消息类型声明出来,并作为它自己的一套发送接口面出现,而不是一包无类型的数据

这个模块拥有三样普通 Event 没有的东西——一场会话内部的顺序、保留时段,以及审核的对象。这就是为什么聊天不是“一个带历史的 Event”:一名玩家的顺序、历史和审核在这里是平台要操心的事,而它们不在 Events 里。

三台状态机。

属于状态
一场会话created → active → closed
一条消息sent → published | rejected by the filter,之后可被编辑或删除,而且是可观察的
一条通知created → queued → delivered | expired

每一条消息都成立的事。

总是成立是什么
order within a conversation是稳定且已声明的。会话之间的顺序不作承诺
editing and deleting是可观察的:一条消息绝不会悄无声息地消失——否则客户端的历史和服务器的会跑偏,而谁也不知道
history就是那些消息本身:带一个已声明的保留时段,从一个位置起按游标分页读取
retention outlives the complaint window这个时段不短于处理一次投诉所允许的时间:投诉在消息之后到来,而一条已经不存在的消息会让人无从处理
read state是一个位置,不是一个标记:每个 Actor 每场会话一个位置,而标记已读是单调的——这个位置绝不后退,所以重复调用不可能把进度撤回去。未读数是那个位置的导出量,而不是一个属于它自己的计数器
sending按键幂等:两次调用就是两句台词,所以那个键正是让重试变安全的东西
blocking是一个投递谓词,不是一次发送拒绝:发送方不会被告知,因为一次拒绝会泄露这次拉黑。这个状态本身住在 Social
the sender composes the payload平台不会去读接收方的数据来填你的文本。接收方的语言环境可以是一个传到扩展点的已声明上下文 claim,所以替换和翻译是那个 Hook 的活儿——那是唯一一个既知道接收方、又知道他们语言环境的地方
the delivery route不属于契约的一部分:推送、应用内还是别的什么,是一个路由决定,不是一项承诺
delivery在已声明的范围之内是可观察的:“已入队”始终可观察;再往后,那条路由能报告多少就报告多少

每一个扩展点都点名它递给 Hook 的类型:发布之前的待发消息,发布之后的已发消息。一个过滤器可以纠正递给它的内容——把一个词遮掉就是一次纠正——但绝不能纠正发送方或者会话。

错误

  • 一场不存在或者被藏起来的会话,以及一个不是参与者的 Actor,都回答 not found——所以一次拒绝绝不会泄露一场你不在其中的会话。
  • 一场已关闭的会话是一次冲突。
  • 没有在这个类型里写入的权限回答 forbidden,而重复没有意义。
  • 被过滤器拒掉是一个裁定,不是一次拒绝。 这次调用执行了,内容被考量了,决定是否定的,而理由是一个已声明的值——这就是为什么它跟一次基于权限的拒绝是可分辨的,也是为什么接下来怎么办取决于那个理由。
  • 过滤器不可用回答 unavailable,值得带退避地重试——但在此期间什么都没有被发布。
  • 一个这场会话没有声明过的消息类型,以及一条超大的消息,都是校验拒绝;内容绝不会被悄无声息地截断。
  • 发送速率超了在限流类别下作答,带一个期限。
  • 编辑别人的消息回答 forbidden。
  • 一条过了有效期的通知是一次冲突:发一条新的。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 消息大小,以及附件和它们的大小——这次发送作为校验失败被拒绝,绝不截断。文件本身归 Files & UGC 所有。
  • 每个 Actor 的发送速率——一次带期限的限流。
  • 历史的深度——过了那个时段,一条消息会带着一个 Event 被逐出保留,而不是悄无声息地消失。
  • 每个 Actor 的会话数——再加入一场作为冲突被拒绝。
  • 每个 Actor 排队中的通知数——新的那条被拒绝,而且禁止挤出:一条被悄悄丢弃的通知,和一条从来没发过的通知无从分辨。
  • 一条通知的有效期——expired,带一个 Event。
  • 一场会话里的参与者数是 Groups 的限制,而每个 Actor 的拉黑数是 Social 的——两者在这里都不重述。

用户流程

一条集结消息到达整个公会。两个角色把投递分开:online-member,他在消息落地时就在这场会话里;以及 offline-member,他收到一条推送,并在下次启动时从历史里把这条集结读出来。

One guild message, two deliveries: the member who is there gets it in the conversation, the member who is not gets a push and reads it out of history on the next launch
Services around the game

Catalog & Commerce

物品、价格、钱包、商城、购买、权益。 在各平台允许的地方接入真实的商店(Stripe、App Store、Google Play、Steam、Xbox);可排程、可按受众投放的商城;以及一条每一步都可挂 Hook 的购买流程。

Who addresses Commerce, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 你要卖东西——通过 Stripe、App Store、Google Play、Steam 或 Xbox 收真钱,或者收钱包里的货币。
  • 商城必须按玩家解析——排程、受众和价格都在服务端算出来,绝不把资格判断的算术放在客户端。
  • 定价规则该待在一个可测试的 Hook 里——折扣、改价和否决都跑在任何扣款之前。
  • 收据必须防重放,而一次退款必须通过发放时用过的那些 Event 把权益撤销掉。
  • 不必用它:东西从来不卖时——不过奖励仍然要通过 commerce 那唯一一个 origin 为 reward 的 Grant 落地(Leaderboards 的周期宝箱就是那样到来的),所以即便一个没有商店的游戏,也保有一份单一的、可审计的发放账本。

谁做什么

Actor在本页
player浏览商城,购买,管理钱包,兑换礼包码
seller配置 catalog、价格和商城排程
backend-service校验收据;通过购买 Hooks 改价或发放

一览

Get the main storefront, already resolved for this player, and purchase from the wallet
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
Coming soon — Go

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

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// 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 — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
Two purchase hooks: before reprices the first buy, after grants the item
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.

模型

一个 catalog 条目声明什么。

声明是什么
key它是由人创作的内容,按一个键寻址,所以在代码里改名就是一次改名
kindconsumable——会被消耗掉;或者 durable——拥有一次就够了
prices一个价格是一个货币量:一个以最小单位计的整数,加上一个货币代码,绝不是浮点数。一个条目可以带好几个——游戏货币和真实货币都行
external identifier per provider每个提供方一个槽位,声明出来,因为一家商店是按它自己的 id 来认这个条目的
what it points at可选地指向任何一种已声明的 Entity,而买下这个条目就授予那个 Entity 的归属

组合被允许成为什么。

是什么
what is purchasable一个 catalog 条目,绝不是一个任意的 Entity:一个没人在背后服务的价格不是一项承诺,因为一次购买需要有人授予那项权利并为退款负责
two levels, no third一个礼包是一个由若干条目组成的 catalog 条目;一个商城是一组售卖项,而一个售卖项指向一个条目,并且可以覆盖它的价格和一个礼包的内容
a territorial price用一个商城来表达,而不是写在条目上

一个商城声明什么。

声明是什么
offers那一组,每一个都指向一个条目
schedule按墙上时钟,始终是 UTC:窗口什么时候打开、什么时候关上
audience一个谓词,而不是一份玩家名单——所以这份受众是一条持续为真的规则,而不是一张快照

一个玩家落不进其受众的商城,对那名玩家来说并不存在。

一份订单的状态。

从到
createdawaiting payment
awaiting paymentpaid、declined、expired
paidgranted
paid 或 grantedrefunded
总是成立是什么
the price在订单被创建的那一刻就在订单里定死了,所以之后的价格变动改不了已经谈定的东西
awaiting payment有一个已声明的期限,按提供方分别声明,因为它们各不相同
granting跟支付是分开的:paid 和 granted 是两个不同的状态:钱到账和东西出现是两个事实,把它们混为一谈会掩盖到底是哪一个失败了
a refund是一次外部的状态转移:它在没有任何我们这边请求的情况下、在任何时候到来,而已经发放出去的东西会怎么样是声明出来的——有三个答案,没有默认值
an entitlement携带它的来源——一次购买、一个兑换码、一份奖励、一次赠送——所以“这东西是打哪儿来的”在一年之后仍然答得上来
a consumable entitlement是累加的:它按一个带幂等键的增量变化,绝不靠覆盖读到的值
ownership是一个所有者谓词:一份权益按跟任何自有行一模一样的机制属于一名玩家
the catalog在代码里声明,并默认以 seed 归属模式抵达面板:代码创建那些不存在的,而设计师的编辑能挺过下一次推送
provider secrets住在运营平面里,绝不在 Declaration 里,也绝不在一个代码仓库里
a provider's capabilities是声明出来的:它到底有没有一套可用的 API,以及它能做什么——这样一份目录就不会承诺一条这家商店服务不了的流程

每一个扩展点都点名它递给 Hook 的类型:购买之前的一个购买意向——玩家、售卖项、提供方、价格——以及购买之后的那次购买本身。一个 Hook 绝不会收到一包无类型的数据。

错误

  • 在受众之外回答 not found,而重复没有意义。在排程之外同样回答 not found,但等窗口打开之后值得再来一次。
  • 提供方不可用和提供方拒绝了这笔付款是有意区分开的两个答案:前者是 unavailable,可以带退避地重试;后者是一次重试也解决不了的冲突。把它们压成一个,会让调用方对着一次拒付永远重试下去。
  • 一张无效的收据是一次校验拒绝;而一张已经被另一份订单或者另一名玩家用掉了的收据是一次冲突——正是这一点让重放变得毫无用处。
  • 从读取商城到下单之间价格变了是一次 precondition failure:重新读取,再作决定,而不是被悄无声息地按新价扣款。
  • 游戏货币不足是一次冲突,不是 forbidden——买的权限是有的,余额不在。充值之后值得再来一次。
  • 一份已经持有的 durable 权益是一次冲突。
  • 区域或者年龄不允许这次购买回答 forbidden,而重复没有意义。
  • 订单的期限过了是一次冲突:创建一份新订单。
  • 消费限额用尽回答为冲突还是限流,取决于超的是哪条限制,而且它会说明这条限制什么时候重置。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • catalog 的规模——再发布一个条目作为冲突被拒绝。
  • 每个 Project 的商城数——创建被拒绝。
  • 一个商城里的售卖项数——添加被拒绝;这个商城绝不会被悄无声息地截断。
  • 一份等待付款订单的寿命——转移到 expired,带一个 Event。
  • 购买尝试的速率——一次带期限的限流。
  • 每个时段的消费限额——一次会说明限额何时重置的冲突。
  • 订单的保留期——过了那个时段,一份订单会按已声明的时段变得不可读,而不是不作解释地消失。
  • 每名玩家的权益数——一次发放被拒绝,而已经发放出去的那些绝不会被挤掉。
  • 一个价格的精度不是一条限制,而是一个类型——一个以最小单位计的整数。

用户流程

一名新玩家的第一次购买:商城解析出来,价格打了对折,物品落到手上——而这笔销售会进入 operator 在 Analytics 里读到的那个首购漏斗。

A first purchase through the whole chain: the storefront resolves per player, a hook reprices before any charge, and paid and granted stay two facts
Services around the game

Inventory

一切都在这里汇合。 开枪扣弹药,掉落落进它,abilities 检查它,移动被它修饰——一组自有的行,带着按增量变化的堆叠,以及一个边界行为由你来选的按所有者上限。

Who addresses Inventory, the 3 things it provides, and the 2 modules it builds on
Firing, drops, ability costs, what you carry and a purchase all meeting in one place, each as a transfer that happens completely or not at all

何时使用

  • 玩家持有东西,而一次持有就是一行带所有者的记录——按所有者读取,按所有者封顶,而溢出行为是声明出来的,不是默认出来的。
  • 一个数量在累加——一个堆叠按一个带幂等键的增量变化,所以一次被重试的扣减不会扣两次。
  • 其他模块从同一组东西里花销——开枪扣弹药,掉落发放战利品,而购买以针对其权益的行的形式出现。
  • 不必用它:这个数字不可被拥有时——hp、xp 和冷却属于 Stats。

谁做什么

Actor在本页
player读取他们自己的持有物并从中花销
backend-service代表一名玩家发放、增减和撤销,并点名它所代表的那名玩家

一览

From fn authority: grant ammo, move an item to the primary equipment slot, check affordability before spending
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);
// fn authority — a cloud function, or a dedicated server holding a host key
const bag = await player.inventory.container('bag');
const equipment = await player.inventory.container('equipment');

await player.inventory.grant('ammo.shell', { count: 20 });
await bag.move(itemId, { to: equipment, slot: 'primary' });

if (await player.inventory.canAfford('ammo.shell', 1))
  await player.inventory.consume('ammo.shell', 1);
# fn authority — a cloud function, or a dedicated server holding a host key
bag = await player.inventory.container("bag")
equipment = await player.inventory.container("equipment")

await player.inventory.grant("ammo.shell", count=20)
await bag.move(item_id, to=equipment, slot="primary")

if await player.inventory.can_afford("ammo.shell", 1):
    await player.inventory.consume("ammo.shell", 1)
Coming soon — Go

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

// fn authority — a cloud function, or a dedicated server holding a host key
Client->Commerce->Entitlements->Grant(FPSIdempotencyKey(GrantId), PlayerId, PSKeys::Item::AmmoShell);

// spending is an instance act on the entitlement you hold
Entitlement->Spend(FPSIdempotencyKey(SpendId), /*Amount*/ 1,
    TPSOnResult<void>::CreateLambda([](const TPSResult<void>& Result)
    {
        // short on the item is a declared refusal, not a silent no-op
        if (Result.IsRefused()) { DeclineReload(Result.Refusal()); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// fn authority — a cloud function, or a dedicated server holding a host key
var bag = await player.Inventory.Container("bag");
var equipment = await player.Inventory.Container("equipment");

await player.Inventory.Grant("ammo.shell", count: 20);
await bag.Move(itemId, to: equipment, slot: "primary");

if (await player.Inventory.CanAfford("ammo.shell", 1))
    await player.Inventory.Consume("ammo.shell", 1);

一个玩家会话用同样这些调用来做读取、移动和“买不买得起”的检查。发放、消耗和销毁不是它能做的:平台会把它们作为 forbidden 拒绝,并点名调用方缺的那项权利,不管这次调用是哪种绑定发起的。

模型

一个 inventory 不引入任何属于它自己的概念。 它是一个 preset——一个由 Entity 已经给出的东西装配起来的形状,所以下面的一切都是一份 Entity Declaration,而不是本页的一套机制。一个需要新种类 Declaration 的 preset,说明的是契约里的一个缺口,而不是把这个 preset 撑大的理由。

声明是什么
一个自有类型这次持有属于一个所有者,而按所有者选择是 Entity 自己的 operation
一个通往 catalog 条目的 ref这个引用存的是那个条目的 id,绝不是它的 key,而这恰恰就是给一个键改名之所以安全的原因。定义本身住在 Catalog & Commerce
一个带增量的堆叠切面一个堆叠按一个差值变化,而不是靠覆盖读到的值。增量在本性上不是幂等的——两次增量就是两次增量——所以它有义务接受一个幂等键,而确定结果的方式是一次按地址的读取
一个带边界行为的按所有者上限三者之一,而且没有默认值:refuse、redirect 到一个已声明的所有者桶里、discard with event

每一次持有都成立的事。

总是成立是什么
the cap has no default对一个满了的包,三个答案是三种不同的游戏:一次拒绝当着玩家的面弄丢战利品,一次重定向是邮件或者一个溢出的仓库,一次丢弃是一次无声的损失——之所以合法,只因为它被声明过而且可观察。没有哪一个对三者都合适,所以由 Declaration 来选
the owner is immutable没有什么东西是靠改一个字段易手的:一次持有的移动,是一次撤销加上一次带已声明来源的新发放,而这两个事实都留在记录里。改所有者则会抹掉那条轨迹,让“这东西我从哪儿来的”和“它是从我这儿被拿走的”在当前状态背后什么也没有
a transfer between two players是另一种承诺:它需要托管和反欺诈,而它不在这个版本的范围之内
a row是展示一份权益,而不是当它的第二个来源——买了什么住在 Catalog & Commerce,而这里的这一行代表它

错误

  • 这个实例不存在,或者被一个谓词藏起来了——两种情况的答案都是 not found,所以一次拒绝绝不会泄露某样东西存在但不归你。
  • 一个没有在这个切面里声明过的字段(嵌套的也算),以及一个没有值的必填字段,都是点名了字段的校验拒绝。
  • 版本不匹配是一次前置条件失败,值得在重新读取之后再来一次。
  • 一次代表某名玩家、却没有点名那名玩家的写入是一次校验拒绝,而不是一次以别人身份进行的悄无声息的写入。
  • 读取或写入没有权限回答 forbidden,而且读和写是分开的。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 每个所有者的实例数——按上面那条已声明的规则来,而且没有默认值。
  • 已存实例的大小——这次写入作为冲突被拒绝,而这次拒绝会点名那个越界的字段和实测的大小。这个上限是靠累积抵达的,所以在那次失败的写入之前,接近它的过程就是可观察的。
  • 单个实例上的变更速率——一次带期限的限流拒绝。
  • 选择集的页大小——这一页被裁到上限,而“还有更多”标记仍然为真;返回更少却不带那个标记,是被禁止的。

用户流程

一发子弹的弹药,从扣掉它的那次施放,一直到把它还回来的那次箱子掉落。那个 ability、那发 projectile、箱子的属性块,以及里面那张掉落表,都是 Entity Presets——是 Entities 上的 Declarations,不是各自独立的模块。

One shot’s ammo out and back: the debit carries an idempotency key because a stack changes by an increment, and grant is a right the player’s own session does not hold
Services around the game

Leaderboards

每一种玩法,都被系统化了。 这不是一份榜单类型的目录。而是一个模型,它的那些轴组合起来就能构成全部:每日排名、最快单圈榜、公会总分、赛季、锦标赛。

Who addresses Leaderboards, the 4 things it provides, and the 3 modules it builds on

那个代码块这样读:谁在本页上行动(actors)、这个模块交给你什么(provides)、它站在哪些模块之上(builds-on),以及它挂在根的什么位置——mounts: root 意思是 playserv.Leaderboards,而不是另一个模块之下的某个命名空间(深入内部)。

何时使用

  • 分数必须给玩家排名——每日排名、最快单圈榜、Groups 总分——作为一个已声明的模型,而不是一块榜一套系统。
  • 你需要那些标准读法——前 N 名、我周围、一份点名的所有者列表——而不必额外建数据模型。
  • 周期必须按排程关闭、归档(绝不删除),并带着最终那张表触发一个奖励 Hook。
  • 可疑的分数绝不能进表——一个提交前 Hook 校验、封顶,或者以一个类型化的理由拒绝。
  • 一场锦标赛就是同一块榜加上一个报名窗口、一个最大参赛人数和每周期的尝试次数。
  • 不必用它:这个数字从来不在玩家之间比较时——一个个人计数器或者一个生涯总数就是普通的 Data & Subscriptions。这个模块给结果排序;它从不计算结果,也不跑任何淘汰赛程。

谁做什么

Actor在本页
player读取前 N 名/我周围/自己的名次,订阅名次变化
backend-service提交成绩;在提交前 Hook 里纠正或者拒绝它们;在一个周期关闭时发放奖励
operator声明榜单;提前关闭一个周期,纠正记录(有审计),观察提交速率

一览

Declaring weekly-score: owner, aggregation, a Monday reset, server submits, the order key
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Coming soon — Go

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

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — the same template class compiles in Unity, on the 2021.3 baseline — no C# 12 syntax here
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

这份 Declaration 跟你其余的 schema 待在一起——在服务端项目里,或者在 UE 或 Unity 项目里——而 playserv push 会把它编译好送上去:这块榜出现在面板里,空的,而它的下一次重置已经排好期。排程都按 UTC,所以这块榜在周一 00:00 UTC 关闭;Reset.Weekly(DayOfWeek.Monday, at: "03:00") 可以挪动那个小时。按玩家本地时间不是一个可选的重置方式——一张表不可能在二十四个不同的时刻关闭。

排序键是一个列表,不是一个分数加一条平分决胜规则。字段按你编号的顺序参与排名,各带各的方向,而最后一档是平台的:在键相等时,更早的提交排在前面,所以两次一模一样的成绩不会在两次读取之间互换位置。排序键之外的字段——这里的 Map——是带着用来展示的,绝不会挪动任何一行。

Agg 说明第二次提交会对一个所有者在当前周期里那唯一一条记录做什么:

Agg第二次提交幂等
Set用提交上来的值替换这条记录是
Best只在新值按排序键排得更前时才替换是
Increment把提交上来的值加到这条记录上——击杀、圈数、公会贡献否——要带一个幂等键
Decrement减掉它们否——要带一个幂等键

一次没能超过 Best 记录的提交不是错误:它会以“已接受、顺序不变”返回。Increment 和 Decrement 是那两个会被重试调用应用两遍的,所以它们跟其他每一次可重试写入一样,接收同一个幂等键。

提交就是一次调用,而在这块榜上它来自服务端代码,因为 Declaration 就是这么说的:

One Submit: the two ranked fields and the display field, from the function that owns the result
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Coming soon — Go

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

A different actor calls this

The call exists in Unreal. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

A different actor calls this

The call exists in Unity. This board keeps the default Submit.ServerOnly, so the platform accepts a submit only from a cloud function or a room host under its host key. Declare Submit.Players and the same call works from the client. See Access & Roles.

那次调用里有三样东西值得分开来读:

调用里的它是什么
PlayServ · playserv云函数的句柄,以及 SDK 在启动时交给你的那个客户端实例。同一套 API,两种调用方——在 Go 里它们是 ps 和 psv,每个 snippet 用的都是它的调用方手上那个
提交方拥有这场比赛结果的那个函数。在 Tanks 里,那是这个 Room 的 on dispose Hook(Rooms),它是拿着最终状态在跑的
playerId来自 Auth & Players 的平台玩家 id,绝不是你自己起的名字:一个 Hook 从它的 payload 上读到它(这一课里的 e.By.PlayerId),而一个 Room 宿主提交的是它所拥有的那个座位的 id

这些值就是 Declaration 点名的那些字段——一个未声明的字段会被拒绝,而不是被存下来。

每个游戏都需要的那些读法,以及让它们保持最新的那个订阅:

Top 100, the window around me, a guild's rows by owner list, and a live rank subscription
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Coming soon — Go

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

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// 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
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) 是一个按名次的窗口,不是一页:你上面五行、下面五行,加上你自己——十一行,在表到头的地方对称地裁剪,所以第 2 名拿到的是两侧都更短的窗口,而不是一个被平移过的窗口。Top 是分页的:它返回前 N 行加一个游标,而 after: 走完其余的。

ForOwners 就是好友榜的做法。平台不持有任何好友关系图;你把你游戏本来就有的那些所有者传进来——一个 Groups 的成员,或者一份来自你自己数据的 id 列表——而每一行回来时带的是它在完整表里的名次,不是在这份列表里的名次。

OnRankChanged 只投递本地玩家自己的名次,别的都不投:一块有五万名参赛者的榜,不会把每一次洗牌都推给每一个客户端。回调收到的是变了的那一行——名次、参与排名的字段、展示字段——而 Cancel() 结束这个订阅。名次本身是一张快照:相隔一秒的两次读取可能不同,因为提交还在陆续落地;不过你自己的提交对你自己的下一次读取总是可见的。

模型

一块榜声明什么。

轴取值你怎么设定它
所有者玩家、GroupsOwner = Owner.Player——一块公会榜就是同一块榜换成 Owner.Group
排序键一个或多个已声明的字段,各自升序或降序[Rank(1, Sort.Descending)] int Score
聚合set、best、increment、decrementAgg = Aggregation.Best
重置一份 UTC 的排程;一个周期过期,绝不删除Reset = Reset.Weekly(DayOfWeek.Monday)
谁可以提交仅服务端(默认)、玩家Submit = Submit.ServerOnly
展示字段已声明且有类型;绝不参与排序[Display] string Map
所有者列表在读取时选定,不是声明出来的ForOwners("weekly-score", ids)——好友、一个公会、一个大厅
锦标赛规则报名窗口、最大参赛人数、每周期尝试次数、需要报名Rules = Tournament.Define(…),在锦标赛那张表里

这里没有作用域这条轴:每区域一块榜、每 Room 一块榜、每赛季一块榜,就是每个键一块榜,而那个键正是你代码所引用的东西。

每一块榜都成立的事。

总是成立是什么
direction and operator在第一次写入之后就不可变了:改动它们会悄无声息地把历史重新排一遍;改一种玩法的办法是开一代新的,不是一次编辑
exactly one entry per owner per generation第二条不是第二行
an entry不是一个 Entity:没有属于自己的生命周期,没有状态机:它由第一次提交创建,并由这块榜所声明的那个算子来改动
fields outside the order key never affect the order它们是展示用的,而这正是它们被单独声明的原因
a generation是过期,不是删除:open → expired → evicted from retention,而过期的那些代在已声明的保留期内仍然可读
the schedule transition靠一个 Event 变得可观察,所以一个处理函数读到的正是刚关闭的那张表,而不是刚打开的那张空表
the default submitter是服务端:谁可以提交是声明出来的,而默认不是玩家
a board是由人创作的内容:在代码里声明,按一个 key 寻址,抵达管理控制台,处在 seed 归属模式之下,所以设计师对排程的编辑能挺过下一次推送
A cycle on a timeline: submissions through it, a hook on the standings when it closes, then a reset with the closed generation still readable

一个周期是什么,以及关闭一个周期会做什么。

是什么
a reset是关闭一个周期,而不是删掉它
a closed cycle不再接收提交,但按它的标签仍然可读——Top("weekly-score", 100, cycle: label),一个读取参数,而不是一个导出作业
the close event携带那个标签,所以一个处理函数读到的正是刚关闭的那张表,而不是刚打开的那张空表

一块榜上的两个 Hooks,而它们的种类不同。

Hook它可以做什么
pre-submit一个 gatekeeper:平台调用它并等着。它可以拿你自己的 Entities 去纠正提交上来的值、给它们封顶,或者以一个类型化的理由拒绝;而如果它自己失败了,这次提交就被拒绝——失败时关闭。它不可以改动这条记录的所有者或者它所属的榜:那些已经被认定了。它返回一个裁定——接受、接受一份被纠正过的提交,或者拒绝——而这次拒绝会以一个类型化的 problem 到达调用方(Core),跟 SDK 里每一次拒绝都是同一个形状
cycle-closed一个 observer:事后触发,它否决不了,而它在那里失败也仍然让这个周期保持关闭
Both hooks on weekly-score: pre-submit rejects an impossible score, cycle-closed grants the top 10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the cycle-closed event. 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the cycle-closed event.

这份奖励是一次 Catalog & Commerce 发放,而不是本模块的一种玩法:chest.gold 是一个目录 id,而 reward 这个来源正是把这次发放和一次购买区分开的东西——退款、撤销和权益变更 Event 在它上面的运作方式,跟在一件买来的物品上完全一样。

锦标赛。 一场锦标赛就是这块榜加上参与约束——不存在第二套机制,也不存在一个单独的 Entity。四条约束,连同它们的单位和它们在边界处的行为:

约束声明为在边界处
报名窗口entryWindow: TimeSpan——周期打开之后,加入还能开放多久窗口关上之后的加入会被拒绝;这个周期仍然跑到它的重置为止
最大参赛人数maxEntrants: int——一个周期里的记录数第 64 名之外的第 65 名参赛者作为冲突被拒绝,而且什么都不会被挤掉——一块会丢掉最差几行的榜,等于是在给先到的人排名
每周期尝试次数attemptsPerCycle: int——每个所有者的提交次数下一次提交回答“尝试次数已用尽”——一次冲突,不是一个权限错误,而计数随周期重置
需要报名joinRequired: true——参赛者是一种成员关系,不是“所有玩过的人”来自非参赛者的提交会被拒绝

每日锦标赛把这四条从头到尾都声明了一遍。

错误

一个 player 会话去调用这块榜留给 fn 的 operation——一次向仅服务端榜的提交、一次提前的周期关闭——会在任何东西被写入之前作为权限错误被拒绝;而同一次调用从一个云函数发出就能过。而一次向一个已经关闭周期的提交则是一次冲突:权利是有的,周期不在了,而重试的方式是向当前那个周期提交。

限制

每一条限制,连同它在边界处会发生什么。

限制在边界处数字
每次读取的行数这一页被裁剪,“还有更多”保持为真,after: 继续页上限按 Project 设定
一个所有者周围的窗口对称地裁剪窗口上限按 Project 设定
一个周期里的记录数这次提交作为冲突被拒绝;不挤出任何东西每块榜的 maxEntrants;不设时无上限
每个所有者每周期的尝试次数冲突“尝试次数已用尽”,由重置解除每块榜的 attemptsPerCycle;不设时无上限
每个所有者的提交速率限流拒绝,带上允许重试的那一刻速率按 Project 设定
每个 Project 的榜数一份新的 Declaration 在部署时被拒绝限制按 Project 设定
已关闭周期的保留期这个周期带着一个 Event 离开存储;之后的读取回答 not-found保留窗口按 Project 设定

用户流程

weekly-score 这块榜的一周:服务端一侧的提交、一次“我周围”的读取、周一的关闭以及它的奖励。

One week of a board: server-side submits with a gatekeeper on each, a window read around the player, and the Monday close whose label is what the reward hook reads
Services around the game

Files & UGC

文件以分块的形式到达,并在到达时就被处理。 上传、资源和它们派生出来的变体,以及带审核路径的玩家生成内容。

Who addresses Files, the 4 things it provides, and the 3 modules it builds on

何时使用

  • 玩家或者服务要上传二进制块——分块、可续传的会话,带可读的按玩家配额。
  • 处理必须在一次上传结束之前就开始——把这个文件当作一条流来读,一块接一块。
  • 玩家做的内容需要一条审核路径——SubmitUgc、一个队列、一个裁定、两端各一个 Hook。
  • 一张母版图片必须服务许多平台——派生出各种变体(缩放、转码),并把原件保持为权威版本。
  • 不必用它:小的结构化载荷——一个 Data & Subscriptions 记录字段不用上传会话就能带着它们。

谁做什么

Actor在本页
player上传分块,读取流式文件,提交 UGC
moderator审阅队列,通过或驳回提交
backend-service派生资源变体;在上传和审核上挂 Hooks;设定配额

一览

Upload tank-07.png in chunks, read it back mid-upload, attach it as a decal
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Coming soon — Go

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

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// 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
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC,玩家那条路径:

SubmitUgc from the client — one call, every binding
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Coming soon — Go

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

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// 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
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

围绕它的那些门禁是 Hooks,跟别处同一份契约:

Hooks gate the upload size and enqueue moderation on submission
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
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

A hook is a cloud function: it executes on the platform, not in the engine. Write it 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

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.

这两个 Hooks 包裹的都是一个 operation 步骤,绝不是一个 Event:Before(Files.Upload) 决定这次上传要不要开始,而 After(Files.SubmitUgc) 在这份提交已经存在之后运行,并通过这个模块自己的 Moderation.Enqueue 把它放进审核队列。而 Events——上传完成、变体就绪、UGC 已提交、审核裁定——是发给订阅者的,而一个订阅者什么都否决不了。

模型

一个文件是不透明的字节加上已声明的元数据——来源、内容类型、大小——而它不是一个用来据以作决定的状态存储。它与游戏模型的联系走的是反方向:你的类型上的一个字段持有那个引用;而这个文件对游戏一无所知。

一种文件类别声明什么。

声明是什么
origin由人创作的内容、游戏生成的,还是用户生成的——而大小限制和策略都由它推出来
admissible content types作为一份已声明的清单,绝不从字节里嗅探
size limits在会话打开时按已声明的大小检查,而不是在最后一块上检查
part size and order上传由一个会话来执行:一个已声明的分块大小、各分块的顺序、一个续传点
derivatives可选:由一个处理函数产出的具名变体——而每一个已声明变体的就绪状态都是可观察的,所以客户端永远不用猜那张缩略图到底出来了没有
storage prefix在携带那个引用的 schema 字段上:字节住在哪里,仅此而已。不是一个目录——不能改名,不能移动,前缀上没有权限,也没有递归操作。这个文件仍然按它的 id 或 key 寻址,而前缀完全不参与其中

每一个文件都成立的事。

总是成立是什么
completion按会话幂等:重复的完成动作返回同一个文件,而不是第二个
a checksum是强制的,而不匹配是一次拒绝,绝不是悄无声息地接受损坏的字节
a published file是不可变的:一次编辑就是一个新版本,而一个指向某个版本的引用仍然指着它原先指的东西
authored content按键加版本寻址,而且它是 managed 的:不在管理控制台里编辑,因为代码拥有它
an unfinished session死得可观察:过了它的期限,它会带着一个 Event 被终结,而它的那些分块被释放
ownership跟着所有者谓词走:用户生成和游戏生成的文件像任何自有行一样有一个所有者,而一个所有者的文件遵守玩家删除策略——级联、拒绝或者匿名化,是声明出来的,不是想当然的
read access可以取决于一份权益:一个付费资源由 Catalog & Commerce 的权益来把关,而不是由第二套权限系统

每一个扩展点都点名它递给 Hook 的类型——上传之前的那个上传意向,提交之后的那份提交——所以一个 Hook 绝不会收到一包无类型的数据。

错误

  • 没有权益回答 not found,不是 forbidden——否则这份拒绝清单就泄露了有哪些附加内容存在。一个被下架的文件回答同样的东西。
  • 一个过期的会话是一次冲突:开一个新的。
  • 一个超出已声明顺序或大小的分块、一个未声明的内容类型,以及一个超出限制的大小,都是校验拒绝——而大小那一条落在会话打开时,不是在字节都跑完之后。
  • 校验和不匹配是一次值得再来的校验拒绝:把那一块重发。
  • 配额用尽是一次冲突,腾出空间之后可以重试。
  • 一份过期的读取授权回答 not authenticated——去申请一份新的授权,而不是把它当成一个权限问题。
  • 被审阅驳回是一个裁定,不是一次拒绝:这份提交被考量过了,而答案是否定的,带一个已声明的理由,所以接下来怎么办取决于那个理由。
  • 上传速率超了在限流类别下作答,带一个期限。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 按来源分的文件大小——这次上传在任何一块被接受之前就被拒绝,不是在最后一块上。
  • 分块大小——这一块作为校验失败被拒绝。
  • 会话的寿命——expired 带一个 Event,而那些分块被释放。
  • 每个 Project 和每名玩家的存储配额——一个新会话作为冲突被拒绝,而已经发布出去的东西绝不会为了腾地方被悄无声息地删掉。
  • 保留的创作内容版本数——最老的那个被下架,而一个被生效中的 Environment 引用着的版本绝不会。
  • 每个 Actor 的上传速率——一次带期限的限流。
  • 游戏生成内容的保留期——过了那个时段,一次带 Event 的下架。

用户流程

一个玩家搭出来的关卡,从第一个上传的分块一直到通过的裁定。

One player-built level from the first chunk to the verdict: the size is checked when the session opens, and the moderation queue is entered by a hook rather than by the upload
Services around the game

Analytics

一切需要事后去数、而不是当下去看的东西。 声明一个类型化的遥测 Event,发出它,它就会落在平台自己那些旁边——一个关卡通关、一个漏斗步骤、一次经济事件、会话时长、新手教程里的一次流失。这个模块发出;它不读取、不聚合,自己也不把任何东西送到任何地方去——一个批次往哪个方向走,是路由器的事,在 Extensibility。

Who addresses Analytics, the 3 things it provides, and the 2 modules it builds on

何时使用

  • 某样东西必须事后被数——一个漏斗步骤、一个关卡通关、一次经济事件、会话时长。
  • 这个比较必须挺过游戏构建的更迭——一个类型携带一个 schema 版本,所以一个一年前的漏斗不会悄无声息地变成同一个字段两种不同含义的拼接。
  • 量很大,而只要你说了,丢一行是可以接受的——遥测是这份契约里唯一一个已声明的丢失是合法的地方。
  • 不必用它:有人必须作出反应时——一个遥测 Event 根本没有任何订阅者;一个别人必须听到的事实是一个游戏 Event。

谁做什么

Actor可以不可以
any actor在 schema 里声明类型;以自己的名义发出,一次一个或者成批;读取那些已声明的类型填写上下文;读取、查询或者聚合已经发出去的东西
backend-service同上,另加通过委托代表一名玩家发出读取遥测——没有读取权限,因为根本就没有读取 operation

一览

Declaring and emitting BossDefeated: named, typed fields instead of a JSON blob
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });
@Event('boss_defeated')
export class BossDefeated {
  bossId = '';
  partySize = 0;
  fightSeconds = 0;
}

PlayServ.analytics.emit(new BossDefeated({ bossId: 'hydra', partySize: 4, fightSeconds: 212 }));
@event("boss_defeated")
class BossDefeated:
    boss_id: str = ""
    party_size: int = 0
    fight_seconds: float = 0.0

playserv.analytics.emit(BossDefeated(boss_id="hydra", party_size=4, fight_seconds=212.0))
Coming soon — Go

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

USTRUCT(PSEvent = (Name = "boss_defeated"))
struct FBossDefeated
{
    GENERATED_BODY()

    UPROPERTY() FString BossId;
    UPROPERTY() int32   PartySize;
    UPROPERTY() float   FightSeconds;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the declared type becomes a generated member under the Emit node
Client->Analytics->Emit->BossDefeated({ TEXT("hydra"), /*PartySize*/ 4, /*FightSeconds*/ 212.f });
// 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 — the same declaration and Emit call compile in Unity, on the 2021.3 baseline
[Event("boss_defeated")]
public class BossDefeated
{
    public string BossId = "";
    public int PartySize;
    public float FightSeconds;
}

PlayServ.Analytics.Emit(new BossDefeated { BossId = "hydra", PartySize = 4, FightSeconds = 212f });

这个定义属于 schema,所以到达的东西携带的是具名的、类型化的字段,而不是一坨 JSON——而且它是以它自己那种承载形式声明出来的,不是一个带标记的游戏 Event,所以它到底是哪一种,不用跑任何东西,从 Declaration 上就看得出来。

模型

一个遥测 Event 类型声明什么。

声明是什么
name这个类型自己的名字
fields按平台的类型系统来定类型;跟别处一样,一个字段掩码同样适用于它们
schema version强制的,而且不是从 SDK 版本推导出来的——一个漏斗要比较在不同游戏构建下采集到的 Events,而没有一个版本,这个比较就会悄无声息地把不可比的东西混在一起
sampling这个类型的 Events 有多大比例能通过。声明在类型上,绝不由实现根据负载去挑:一个会自己变的比例会让漏斗在不同日子之间不可比,而这一点只有在人们已经基于它作了决定之后才会被发现
loss tolerance这个类型容不容忍丢失。遥测是这份契约里唯一一个已声明的丢失是合法的地方
deletion behaviour一名玩家的删除如何抵达这个类型——是删掉还是匿名化。工作室声明这条策略;这个模块去执行

每一个遥测 Event 都成立的事。

总是成立是什么
no addressing target没有接收者,没有 Group,没有订阅。想给它寻址,就是“你要的其实是一个游戏 Event”的信号
context是平台的:它加上那个 Actor 或者一个匿名标记、会话、Environment、构建版本,以及按已声明时钟的那一刻。调用方填不了它:一个想替换掉 Actor 或者构建版本的调用方,拿到的是推导出来的值,而不是它传进去的那些
the sampling share随 Event 一起走:没有它,就没法从到达的东西里还原出绝对数字
loss在总量上是可观察的:一段时间内未送达的比例对消费方可得;逐条的可观察性不在承诺之列,因为在遥测的量级上,每丢一条发一条消息,本身就会变成一条流
emission不是幂等的:两次调用就是两个事实,而它不接受幂等键——把第二个压掉就是丢数据。没有办法去确定一次丢失了的发出的结果,而我们也不想要:丢失容忍度是预先声明的,一次性对所有调用生效
the events就是那份历史:这个模块自己不保留任何历史

错误

  • 一个未声明的类型,以及一个跟这个类型 schema 不匹配的字段,都是校验拒绝——两者都不是运行时的意外,因为一个类型是从它的 Declaration 抵达管理控制台的。
  • 一个超大的 Event 是一次校验拒绝;字段绝不会被悄无声息地夹住。
  • 速率超了在限流类别下作答,带上那个在此之前重试都没有意义的时间。
  • 一次采样丢弃不是错误,一次可接受的丢失也不是。 这次调用执行了,而这次丢弃是已声明的行为;把两者中的任何一个报为失败,都会让已声明的行为和一次故障无从分辨。
  • 一个批次要么整体、要么按元素,而是哪一种是声明出来的——绝不是“发生了什么就是什么”。

限制

每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。

  • 每个 Actor 的 Event 速率——一次带时间的限流拒绝。超出速率绝不会悄无声息地丢弃:要么是那次拒绝,要么是一次已声明的采样丢弃,没有第三种结果。
  • 单个 Event 的大小——一次校验拒绝,绝不会是被悄无声息夹住的字段。
  • 批次大小——在发送之前就被拒绝,而不是被部分应用。
  • 每个 Project 的已声明类型数——一份新的 Declaration 在部署时被拒绝,不是在运行时。
  • 一个类型里的字段数——同样,在部署时。
  • 保留时段——一旦它过期,这个 Event 就按已声明的时段不再可得。

用户流程

那记致命一击变成一个类型化的遥测 Event,按它的 Declaration 被采样,随后被数进去。承载它的那个 ability 和那个 Stat 都是 Entity Presets,不是模块。

Beyond the SDK

运营平面

被有意排除在 SDK 之外的那些东西。 Project 和 Environment 的生命周期、部署和回滚、计费、组织与用户管理、集群路由——这些属于管理面板、CLI 和 MCP 接口面,不属于游戏代码。唯一一处有意的例外是 Schema as Code:schema 是一个面向开发者的接口面,所以它在 SDK 里。

一个模型,两个平面

SDK 平面运营平面
从哪里够到游戏代码控制面板、CLI、MCP
持有Rooms、Entities、玩家、commerce、LeaderboardsProjects 与 Environments、部署和回滚、计费、组织与用户管理、集群路由
工作方式Declarations、Hooks、Events、Operations面板自己的那些界面

它们共享一个模型:你推上去的那份 Declaration,就是面板渲染出来的那一份。跨不过这条线的是权威——游戏代码不能部署、不能计费、也不能搬动一个租户。

每一个 SDK 接口面——每个模块,以及声明在 Entities 上的那些 presets——在控制面板里都有一个运营侧的对应物,同样那些 Declarations 在那里从另一侧被查看和编辑:

SDK 接口面Operator 看到的
Schema as Code / Data & SubscriptionsEntities、迁移、记录浏览器、保存的视图、导入/导出
Entity状态机、按实例的检视
Entity Presets掉落表,ability、Stat 和 projectile 的定义,世界物体 presets——可实时调参
Access & Roles角色网格:角色 × operations、行过滤器、列掩码
Extensibility带覆盖的场景链、解析后的顺序、调用追踪
Rooms机队:Rooms、tick 健康度、放置、排空状态
Matchmaking队列、在途的 tickets、放宽曲线
Catalog & Commercecatalog、商城排程、收据、退款
Leaderboards周期、记录纠正(有审计)、提交速率
Auth & Players提供方、会话、封禁、认证场景
Files & UGC资源、UGC 审阅队列、配额
Analytics仪表盘、转发器、摄取延迟
Map地图和障碍物集合、在线实例
Visibility / Collision / Locomotion / Prediction & Lag Comp按 Room 的调参:规则、响应配对、窗口、按 Actor 计的分包开销
Groups / MessagingGroup 浏览器、模板、审核过滤器、排程
Bots档案、填充配额、大脑端点
Inventory / Profile持有物和转移、视图和自有 Entity 集合

这条设计规则。 一项在面板上没有对应接口面的 SDK 能力,对 live ops 来说是隐形的;一个没有 SDK 能力支撑的面板接口面,是一句谎话。模块两半一起交付,而一份 Declaration 不管在哪一边写下,在两边都是同一个模型。

一份 Declaration 的旅程:schema-author 写下它,playserv push 把它带上去,panel 为 operator 渲染它,而那次重新调参落在已经在跑的那些 Rooms 上。

智能体访问

面板所显示的一切,工具也都够得着:平台暴露了一个 MCP 接口面(就是面板所用的那同一套 API),所以 AI 智能体和脚本可以在跟其他任何 Actor 同一套访问模型之下去操作 Projects(bootstrap、schema、记录、玩家、部署)。

Beyond the SDK

深入内部:传输与 hub

这是一份架构参考,不是一套你会去调用的接口面。本页上的任何东西都不出现在你面对的那套 API 里:没有 socket 要打开,没有通道要挑,没有信封要填,也没有重试要排。你的游戏代码永远不会碰上本页的这些机械——而这正是重点。 核心概念点出了这个栈;而机制只住在这里。它在这里,是为了让一位架构师能查清楚 SDK 拿一次掉线、一个被禁用的模块,或者一条必须恰好到达一次的消息,究竟是怎么办的。

层级栈

The runtime stack from your code down to the transports, with the line below which you never call anything

五层,自上而下:用户空间、模块、Primitives、hub,以及它下面的传输适配器。上面两层是用户空间;下面的一切都是 SDK 自己的事。

  • 用户空间是你的代码。它看到的是模块,而词汇到此为止。
  • 模块是应用层:Rooms、Matchmaking、Inventory、Leaderboards。它们构成一张图,而不是一棵树,而这正是当其中一个被关掉时 hub 必须解开的东西(Inheritance & Composition 是这个形状本身)。
  • Primitives 是所有东西都引用的第一层实现:Data、Events、RPC、Groups。一个模块就是一组被命名的 Primitives 装配,加上它自己的规则。
  • hub 是那个控制器:依赖注入、模块挂载、用户会话、状态恢复、消息服务质量,以及把每一条入站消息路由到为它挂载的那个模块。
  • 传输是搭在某个协议上的适配器。存在好几种;hub 对它们一视同仁。

传输是适配器

传输不止一种,而它们的差异如果不这么处理,就会渗进每一个模块:

轴取值范围
形态消息驱动或请求驱动
通道单通道或多通道
状态带连接状态恢复,或者不带
协议TCP 或 UDP

今天这意味着 WebSocket、PlayServ 的 UDP 传输,以及普通 HTTP。每一种都是一个藏在它自己实现细节背后的适配器,而每一种向上暴露的都是同一样东西:一个传输会话。hub 持有的是一个会话,绝不是一个 socket,所以适配器之上的任何东西都不用去琢磨网络接口。

一条已声明的边界。 同一时刻一条传输通道、一个传输会话。一边跑一条后端传输给 Leaderboards、一边由一条 master-client 传输承载在线会话,不在范围之内,而这套 API 也不承诺它——没有哪个签名是只有在开着好几条通道时才有意义的。之后的版本会不会把它打开,要跟按 Project 的不变式接口面一起定;在那之前,单会话这个形状就是契约。

hub 把传输彻底藏了起来

向下,hub 说的是传输接口。向上,它提供状态、Events 和用户会话。模块代码和游戏代码同样都无从分辨底下是哪一种传输、hub 怎么把一次调用批起来了,或者它在一次空档之后做了什么才回到一致的状态。

  • 用户会话属于 hub,不属于任何模块。重连、续接和状态恢复只在 hub 那里发生一次,为挂在它上面的一切服务。
  • 消息 QoS 属于 hub,不属于 Data 模块。信封、重试和打包都是 hub 的机械。

消息 QoS——恰好三档

一个模块只声明它所需要的那项投递保证:

档位含义
at least once一直重投直到被确认;接收方容忍重复
at most once只发一次,绝不重试;丢失是可接受的
exactly once去重并确认;贵的那一档,只在被要求的地方用

那份声明就是关于投递的全部对话。这项保证是怎么做到的,不是这个模块的事,也不是你的事。

DI、挂载,以及被禁用的模块

hub 实例化各个模块并把它们挂上去——挂在根上或者挂进一个命名空间——同时解析每个模块对 Primitives 和对其他模块的依赖。一个不需要某个模块的构建就不会挂载它。挂载是带命名空间的,而第二个模块去认领一个已被占用的挂载点,会在挂载时就被拒绝——组合在那里就失败了,绝不会拖到第一次调用进去的时候。

因为模块构成一张图,把其中一个关掉会对下游产生后果,而 hub 恰好走两条路中的一条:

  1. 把依赖链整个禁用掉。 每一个需要那个缺失模块的模块也一起关掉,而它的那些接口是缺席的,而不是会失败的。
  2. 声明功能降级。 那些依赖方保持挂载,并宣告它们不再能做什么。

不存在第三条路。悄无声息地半工作——一个已挂载的模块默默丢掉它不再能执行的那些 operations——正是这条规则存在所要防的那种失效模式,也正是为什么一个被禁用的依赖是可观察的,而不是神秘的。

为什么你不会碰上这里的任何东西

各个模块页上的每一项承诺,都是在这条线之上兑现的:一次 Entity 修改就是那次网络 operation,一个 Hook 就是一个类型化的函数,一次加入就是一次调用。这个栈的名字可能会传到你耳朵里——核心概念指向这里——但承诺的是你永远不必调用这里的任何东西,而不是这些词是秘密。下面那些层之所以存在,是为了让那些承诺能挺过一次传输的更换,而一页你永远不必读的文档,就是这件事奏效的度量。

你会碰上的东西——你的处理器所运行的那个投递上下文、一个句柄什么时候终结,以及你用来测试的那份内存实现——在上一页:线程、生命周期与测试。