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.
The simulation is not your code
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.
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
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:
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)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
[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 block | Comes from |
|---|---|
Vector3, Stat | your binding's core package |
Body, and the body shapes | Collision |
Motion, and the five movement models | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | the entity presets that use them |
EntryRequest, Verdict, StatEvent | hook payloads, handed in by the module you hook |
Seat | Matchmaking |
Scope, the sync scopes | Visibility |
Tick, the tick rates | Rooms |
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:
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)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
[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:
| Key | What it names |
|---|---|
rock | a prop in the map's obstacle set (Map) |
ammo.shell, railgun | catalog items (Catalog & Commerce) — which is also how the shot debits ammo and the pickup lands in a bag |
battle, arena, crate-loot, shell | the 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:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 line | What actually performs it |
|---|---|
Stats.Depleted fires | the stat reaching its floor — 0 for Hp, since the declaration set only Max |
| the death transition | AtMin = "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 |
RollAt | generated onto the [DropTable] declaration — which is why it is partial, and why the Go tab reads drops.RollAtCrateLoot |
| the pickup | driving 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):
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)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.
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:
| Call | What it answers with |
|---|---|
Find | one 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 |
Join | resolves 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 |
Cast | firing 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) |
Abilities | generated — 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 boundary | What the caller gets |
|---|---|
| a join past capacity, or into a closed room | conflict — worth retrying when a seat frees |
| room creation past the per-project or per-actor limit | refused, and nothing already created is disposed |
| creating rooms or signing in too fast | a rate-limit refusal carrying the time to wait |
| an event payload over the room's cap | refused before it is sent, never truncated |
| a read past a role's row ceiling | the 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
- 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.
- 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).
- 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 are | Read, 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 dev | Rooms (Hosting a room) → Bots → Locomotion · World Objects → Map → What Survives Losing a Host |
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.
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)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
[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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
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))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.
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
- Leaderboards — the axes, cycles, tournaments, and the pre-submit hook that caps suspicious scores.
- Extensibility — every hook point, in order, with the veto contract.
- Entity presets — the stat threshold that fired the kill in step 2.
- Health crates in Tanks — the other Tanks example: two declarations and one hook.
- Getting Started — the Tanks arena this lesson extends.
- Core Concepts — the vocabulary every module page assumes.
Not translated yet — showing English.
Health crates in Tanks
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.
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)])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
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.
[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)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
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 healthwith 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.
Adjustemitschanged; 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
| Before | After | |
|---|---|---|
| a damaged tank | stays damaged until it dies | can recover by moving through the arena |
| room code | none | still 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
entityrather 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.
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.
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)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
[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.
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)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.
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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
- Leaderboards — the tournament axes (entry window, max entrants, attempts).
- Matchmaking → Rooms — parties, placement and seeding.
- Extensibility → Commerce → Messaging — the hook chain that pays out.
- Core Concepts — the vocabulary every module page assumes.
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 surfaces
Every module exposes exactly four things, and every module page is organised around them. This is the programming model:
| Surface | Meaning |
|---|---|
| Declarations | what exists and how it behaves, authored in code or in the admin panel; the same model either way |
| Hooks | your rules, called by the platform at named steps; deployed as cloud functions |
| Events | what the platform tells you happened — subscribe, don't poll |
| Operations | what 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:
| Tag | Surface |
|---|---|
fn | Cloud function (C# · TypeScript · Python · Go). Server-authoritative; the primary home of your rules |
cl | Game client (Unreal C++ / Unity C#). Symmetrical API; roles unlock less |
mc | The room-host surface: a master-client (a client that owns a room) or an Unreal dedicated server under its host key |
adm | Admin 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.
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
| Question | Answers |
|---|---|
| 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 function | game client | room host | admin | |
|---|---|---|---|---|
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.
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.
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 —
CanIevaluates 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
| Actor | On this page |
|---|---|
operator | declares roles and policies, sets row/column limits, grants roles, issues keys |
match-organizer | the tournament staff of the flow below: holds a composed key, gates entries, cannot refund |
every actor | checks CanI before acting; sees only its unlocked interfaces |
At a glance
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()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.
| Term | What it is |
|---|---|
atom | one 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) |
role | a 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 preset | ships 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 predicate | which rows — a boolean predicate over session values |
field mask | which 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.
| Credential | What it unlocks |
|---|---|
player key | an engine build carries it; the player behind it arrives with sign-in, and the build sees the cl rows of every operations table |
host key | a dedicated server or a master-client holds it, and its roles unlock the mc rows |
pushed code | runs as the project's backend-service role — that is what a leaderboard's Authoritative = true checks |
a registered hook | grants 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.
| Always | What it is |
|---|---|
a credential | carries 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 |
delegation | changes 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 verb | answers 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 row | answers not found: a refusal must not become an oracle for existence |
an owner | always sees itself, whatever else a predicate says |
visibility | is not security — a channel optimisation and a permission are different mechanisms, and neither substitutes for the other |
a disabled module | has 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 surface | follows 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 |
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, notforbidden— 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
forbiddenwhere 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.
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
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.
| Level | What lives here |
|---|---|
| The common principles | Identical 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 shape | Only 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 shape | Only 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.
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
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.
// 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, idempotentAttribute-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.
// 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, idempotentShutting 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.
// 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 downAttribute-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.
// 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 want | What it is |
|---|---|
| stop delivering to me | local. Always succeeds, including with the connection down. Releasing a subscription is this. |
| stop the work | a 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
- Getting Started — the first room, end to end.
- How the SDK Is Built — why there is one surface and how the pieces fit.
- Under the Hood — the layer beneath this one, if you are curious.
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.
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
Problemwith a stable code. - Skip it when you are after messaging, calls or state — those are the primitives: Events, RPC, Data.
Who does what
| Actor | On this page |
|---|---|
any actor | reads identity, context, roles via Whoami |
backend-service | acts as a player; batches idempotent operations |
operator | reads traces for failed or retried calls |
At a glance
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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.
| Field | What it is |
|---|---|
code | the 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 |
category | the class the refusal falls into, which is what says whether a retry is meaningful at all |
trace identifier | the id of this one occurrence, present always, local errors included, so contacting support never requires reproducing the fault first |
explanation | human text for a person to read, and it is not stable: titles and explanations change and are localised at any time |
per-field errors | the 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.
| Origin | What happened |
|---|---|
platform | it answered with a refusal, carrying a code from the platform's catalogue |
local | the SDK refused before sending, from its own published vocabulary |
unknown | the call was sent and no answer came back. Neither "the platform said no" nor "we never asked" |
What is true of every refusal.
| Always | What it is |
|---|---|
a refused operation applied nothing | atomicity 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 path | does 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 timeout | is 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
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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.
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.
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.andon.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
| Actor | On this page |
|---|---|
schema-author | declares events with [Event], pushes the schema |
any actor | emits via send., subscribes via on. |
At a glance
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))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.
| Declares | What it is |
|---|---|
name | an explicit wire name, declared rather than derived from the symbol |
payload | the schema of what an emission carries |
target | where 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 |
clock | sim_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 |
retention | transient — 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 |
term | on a retained type: how long it is kept, and what happens at expiry. "Forever" is not one of the values |
delivery | at 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 |
context | the 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.
| Field | What it is |
|---|---|
type | the 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 |
payload | conforming to the type's schema |
source | the 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 |
stamp | on the type's declared clock |
dedup key | present under every delivery mode, because redelivery is possible in all of them — a transport duplicate, a second read of a retained event |
cause key | on 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.
| Holds | What it is |
|---|---|
event | the declared type it is bound to |
surface | the node it is taken on, inside the type's declared target — the subscriber's half of the audience |
handler | typed to the payload |
position | where 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.
| Always | What it is |
|---|---|
audience | never 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 |
phases | emitted, then delivered — and nothing else. An event has no state machine: it happens once |
ordering | promised 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 streams | when 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 detection | where 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.
| Transient | Retained | |
|---|---|---|
| Reaches | whoever is subscribed at that moment | that, and a subscriber arriving afterwards |
| Afterwards | gone | kept for a declared term |
| Readable back | no | yes, over the term |
| Past the term | — | a selection refuses, rather than answering empty |
[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)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.
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.
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
| Actor | On this page |
|---|---|
schema-author | declares RPCs, their modes and who may call them |
any actor | invokes an answering or a one-way call, where the declaration allows it |
group member | answers a fan-out call; one answer travels back per member |
At a glance
[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)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.
| Declares | What it is |
|---|---|
name | from the vocabulary of verbs |
input | the arguments the caller must choose |
output | exactly 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 mode | with 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 mode | immediate — 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 |
streaming | whether the input and the output arrive in parts and are handled as they arrive, rather than as a whole |
idempotency | a one-way RPC carries an idempotency key too: no reply does not mean no redelivery |
overridability | declared on the method itself. No declaration means not overridable — never overridable by default |
context | where 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.
| Carries | What it is |
|---|---|
arguments | only what the caller must choose |
implicit context | the 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 |
references | an 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 |
outcome | a value of the declared output type, or a typed Problem |
What a deferred call's descriptor holds.
| Holds | What it is |
|---|---|
state | accepted → running → completed or failed, the last two terminal |
lifetime | declared; past it the outcome is unavailable and asking for it is a refusal, not an empty answer |
cancel | idempotent, 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.
| Always | What it is |
|---|---|
one handler | exactly 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 |
meaning | a 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 machine | a 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 stream | is 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 write | no 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.
[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 ranexport 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 ranAttribute-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 ranErrors
- 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.
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.
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
| Actor | On this page |
|---|---|
schema-author | declares aspects, their sync policy and the visibility predicate |
any actor | subscribes to a target; resumes from a position; requests full state |
backend-service | before/after change hooks |
operator | reads per-actor packet cost; sees when delivery degrades or a packet is cut |
At a glance
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 consequenceThe model
What a delta carries.
| Field | What it is |
|---|---|
changed fields | only those, never the whole object |
pair | the instance × aspect it belongs to |
number | a 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.
| Declares | Values, and what it is not |
|---|---|
priority | orders 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 rate | an upper bound on sending. Not a promise of receiving at that rate — receiving depends on the channel |
delta only | do not send what has not changed |
delivery mode | shared 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 rule | the predicate deciding who receives at all — Visibility projects that half in full |
What a subscription holds.
| Holds | What it is |
|---|---|
target | an 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 |
position | where 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 |
state | active → gap detected → resynchronised | closed, and closed is terminal |
What is true of every stream.
| Always | What it is |
|---|---|
merging | deltas 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 detection | silently losing a delta is forbidden; the sequence number in the pair is what the consumer counts |
ordering | holds within one instance × aspect pair; between pairs it is not promised in any form |
traversal | is 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 |
history | is 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 budget | degrades 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.
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | What it may do |
|---|---|
| before a change | mutate 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 change | add 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
closedis 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.
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.
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
| Actor | On this page |
|---|---|
player | creates 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-owner | room seat rules ride this primitive (configured in Rooms) |
backend-service | declares group types and their rules; hooks on entry and exit |
At a glance
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 groupAttribute-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 groupA 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.
[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: ...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() };
[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.
| Declares | What it is |
|---|---|
name | the type's own |
membership mode | explicit — 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 |
rule | for a dynamic group: the predicate, in the same predicate language as access predicates and transition guards. The platform recomputes it; nobody polls |
capacity | and the behaviour on reaching it |
entry rule | a predicate that may reject entry, separately from a hook that may also reject it |
lifecycle behaviour | on 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 |
lifetime | optional: once it expires the group closes with an event |
What is true of every group.
| Always | What it is |
|---|---|
member | an actor, never an entity: a set of entities is a selection over Data. A group is one mass listener |
states | created → active → closed, and closed is terminal. A group instance has a machine; the type does not declare it |
event target | emit on it and its members receive; that is what makes bulk delivery one signal rather than a loop |
a group call | is 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 outcome | never 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 |
recipients | are never enumerated by the sender: membership decides, so a sender does not have to know the composition of the audience |
intra-group roles | do not exist: a "group owner" is an actor holding a right (Access), not a rank stored in the member list |
first entry and last exit | are distinguishable from the joins and leaves in between — which is what the declared lifecycle behaviour and round initialisation hang on |
recomputation | carries 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 leave | are 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 interface | is a concrete group's, not only the type's: you address this squad |
the primitive | stays 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.
AddorRemoveon 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 = 4above); 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.
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.
When to use it
- A platform step must run your logic — declare the replacement for a named link with
[Override(…)]. - You need checks or side effects around a step — ordered
Before/Aftermiddleware that can veto or notify. - Code must run on a schedule, an event, or a webhook — triggers hand you a parsed, typed context.
- You must know what will actually run before deploy — dry-run a chain and read the resolved order.
- Skip it when the rule concerns one entity's writes — a Data hook is the lighter form.
Who does what
| Actor | On this page |
|---|---|
backend-service | overrides links, wraps steps with middleware, writes trigger handlers |
operator | inspects chains, sets order, reads secrets, dry-runs resolution |
At a glance
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
The model
What the platform gives you to attach to.
| Term | What it is |
|---|---|
registered function | one overridable platform step — "create profile", "resolve price" |
scenario | the ordered chain a platform flow runs: auth, join, purchase |
overridability | whether a link may be replaced, wrapped only, or is fixed |
middleware | an ordered pre/post handler around a link |
trigger | what starts your code: an event, a schedule, a webhook |
secret | a value your handler may read |
invocation | one run, with its trace |
What a hook declares.
| Declares | What it is |
|---|---|
position | the named step it attaches to |
kind | gatekeeper — an admission check or a validation, and it fails closed, so the step does not run when the hook itself breaks; or observer — a log, a notification, a counter, and it fails open, the step runs, and the failure is still reported rather than swallowed. There is no default |
moment | before — ahead of validation, receiving the typed payload, and it may mutate or reject; or after — once the step committed, receiving request and result, side effects only, and it can never fail the operation or alter the response |
effect | what the hook does, not only where it sits. 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 condition | a handler that applies to one environment or audience is a declared version of that hook rather than a branch inside its body, and it is what the panel's toggle switches |
Three ways to attach code.
| Shape | Use it for |
|---|---|
[Before(Step)] / [After(Step)] attribute | one rule on one named step — most hooks |
[Override(Link)] attribute | replacing a link's implementation outright |
Extend.Scenario("…").Before("link", fn, order: n) | wrapping a link inside a chain, when order against other middleware matters |
| Always | What it is |
|---|---|
all three | deploy with playserv push |
the two attribute shapes | are what the panel renders, because the declaration carries the step or link name into the pushed model |
the middleware form | carries an order instead, which is what a chain needs |
assigning at startup | (Scenario.OnX = fn) stays available for a handler that does not need to appear in the admin tree |
replacing one link | leaves the links either side untouched, and neither of them knows which implementation answered — the platform's own step or yours |
Where a call goes, and what is true of every route.
| Always | What it is |
|---|---|
four directions | a cloud function · the consumer's external backend · the game server · another declared one |
the router | is message/signal-driven; request-response is one adapter onto it rather than its nature |
matching | is on the declared name of the operation or signal and on nothing else: not payload shape, not the caller, not load |
a name registered twice | is a defect of the declaration, refused when the set is declared rather than resolved at call time |
an unregistered name | answers not found, rather than being silently dropped |
the direction | is not part of the operation's contract: moving a handler between directions is not a breaking change |
"the game server" | is defined by what it is, not by who hosts it — our fleet and a studio's own hosting are one direction, and the declaration carries no marker of who owns the infrastructure |
game-server RPCs | register in the same router: declaring one is registering it, and there is no second way |
ordering | runs middleware top to bottom, and where a step has more than one implementation the router picks left to right by condition, with the version marked default answering when nothing matched |
What a constraint between hooks is, and when it is checked.
| What it is | |
|---|---|
a named constraint | an extension point may name the effects it constrains — an anti-cheat check must precede a placement, a receipt requires a charge at this point — and constrain nothing else |
an unnamed effect | is unconstrained, not refused: a consumer doing something nobody foresaw is what the mechanism is for, and a closed vocabulary would turn that into a registration-time rejection |
a violation | is a defect of the configuration, and the refusal names both hooks and the constraint they broke — not a warning, and not a silent reordering |
when it is checked | at every act that can change what runs at a point: registering, deploying, changing the arrangement. So an arrangement that reaches execution has already been admitted |
never re-checked at run time | that would be a second answer to a settled question, asked at the one moment nothing can be done about it |
| Always | What it is |
|---|---|
handlers | are typed in and out: no dynamic, no context bags. The platform handle is ambient, and the invocation context — caller, trigger, trace — arrives parsed |
what an engine build sees | the events the scenario emits afterwards, because an override or a middleware runs on the platform and an engine runtime is not a place to host one. That is what the @na tabs on this page's samples mean by subscribing to the resulting events |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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-openorfail-closed— and which one it is was declared rather than inferred from what happened. - A hook may not change what is already claimed: not the owner, not the target, not the board or conversation a call was addressed to. It corrects inputs and returns a verdict.
Limits
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.
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.
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.
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 codegenregenerates 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
| Actor | On this page |
|---|---|
schema-author | declares entities/parts/enums in code, diffs and pushes |
operator | reviews the panel overview, proposes and applies migrations |
ci | the build pipeline, running under a backend-service key: pushes on a merge and regenerates engine types afterwards |
At a glance
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 = 0Attribute-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
[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.
| Carries | What it is |
|---|---|
key | the stable name it is addressed by. A rename in code is a rename, not a delete-and-create |
kind | an entity, a part or an enum, declared in code |
ownership mode | seed — 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 |
preset | optionally: 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.
| Always | What it is |
|---|---|
matching | is by key, never by symbol: a repeat push after renaming the symbol leaves one record rather than two |
idempotency | follows from that — a repeat push is not a second record |
the report | says exactly what will change before applying, and what it overwrote afterwards |
origin | is distinguishable: a record created by a push from code is told apart from one created elsewhere |
the revision | rides with it, and a push lands whole or not at all |
What codegen promises.
| Always | What it is |
|---|---|
regeneration | happens after every push, and generated types are never hand-edited: regenerating and then diffing produces no change |
naming | follows the declaration wherever it was authored — the Rarity field on Item becomes UPSItem::Rarity in Unreal and Item.Rarity in Unity |
the two directions | do 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.
| Always | What it is |
|---|---|
when one is required | a persistent declaration change that rewrites existing values, and a breaking persistent change cannot be published without one |
what it declares | a version, a preview, an ordered apply, a rollback on failure, and an observable completion outcome |
coexistence | while 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
forbiddenand the deployed model is untouched: a refusal is never a partial push. That is a different refusal from a stale revision, which isprecondition_failedand 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.
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.
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
| Actor | On this page |
|---|---|
schema-author | declares entities, aspects, state machines, presets |
every actor | queries, subscribes, calls entity RPCs, reads state |
At a glance
[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()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();
};
[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.blockedassociates withopenon its own, so theclose_requestedtransition declared onopenapplies inside it without being repeated. While the machine sits inopen.blockedit is inopen— a state check foropenis true, andOnEntered("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
openingdrops itsAfterSeconds, 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 declaration | What it means |
|---|---|
caller in entity.room | any 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 |
Actor | the caller's identity, the same object whoami returns |
caller.Inventory | Inventory'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:
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"))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.
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
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.
| Declares | What it is |
|---|---|
aspect | a named field group declared whole, with its own sync policy and access mask. An entity carries several, and no field is in two |
state machine | states nestable one level, transitions and guards; several per entity |
trigger | what fires a transition — the four sources are below |
entity RPC | a verb sticking out of the entity, declared inside the view with the right atom it needs |
entity event | a signal the entity emits, delivered to whoever subscribes to that instance |
hook | pre 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 track | whether the view keeps the instant window at all |
ref | a 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.
| Source | How it fires |
|---|---|
client event or RPC | any declared one — RequestOpen above fires open_requested |
collision | a contact or a trigger-volume entry — traps, pressure plates — through the aspect Collision binds to |
data threshold | declared on a stat, 0 HP → death, enforced by hook order rather than by code in a room |
time | AfterSeconds 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.
| Axis | What is admissible |
|---|---|
filter and sort | declared fields only — there is no table handle, and a selection is entity-addressed, scoped to a room or to the project |
include | a declared ref, pulled in with the page |
paging | by opaque cursor: not an offset, not a row id, and its meaning does not survive a version change. Pass it back, never parse it |
access | predicates apply before paging, so a page never carries holes where hidden rows would be |
live | subscribing to a selection keeps it live, with members entering and leaving as their data changes |
// 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()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.
// 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.
| Always | What it is |
|---|---|
a selection | is 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 request | stays 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 guard | is a different operation with a different atom — entity × administer, which no client key holds by default |
history | is 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 change | is 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 implementation | is 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 declared | is 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 call | does 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 instance | carries 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:
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})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.
// 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.
| Limit | At the boundary |
|---|---|
| aspects per view · machines per view · nesting depth inside an aspect | the declaration is rejected at playserv push, never silently truncated |
| stored instance size | the write is refused as a conflict, naming the field and the measured size; approaching the limit is observable before the refusal |
| selection page size | the page is cut to the cap and "there is more" stays true — you never get a short page that looks final |
| instant-history window | a read outside the window is refused, not answered with the nearest value |
| change rate on one instance | a rate-limit refusal carrying how long to wait |
User flow
One door, from its declaration to the chime the player hears.
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
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.
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.
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
| Actor | On this page |
|---|---|
schema-author | declares stats, abilities, projectiles, drop tables, world objects |
room-owner | tunes preset numbers, rolls drop tables, creates world objects |
player | casts abilities, fires shots, picks up loot, interacts with objects |
At a glance
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})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.
// 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
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.
| Preset | What the contract gives it | Where it is tuned |
|---|---|---|
stats | an 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 code | the field's declaration; the numbers stay live-editable in the panel |
abilities | an aspect of a set of abilities, a machine of application phases, and cost and cooldown | the ability's declaration |
projectiles | a type with runtime persistence, a ballistics aspect, and a hit event | the projectile's declaration — one attribute swap changes the flight model |
drops | an aspect of a drop table with weights, and a hook after death | the table's entries and weights |
world objects | a state machine of an interactive object, and an aspect of the interaction condition | the preset's declaration, or per instance at creation |
| inventory | an owned type with a ref to a catalog item, a stack aspect with an increment, and a per-owner cap with declared overflow | the 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.
| Always | What it is |
|---|---|
where it sits | on 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 |
tuning | is 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 one | is 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 own | is 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 —
fnoradm. A player or client key attempting one getsforbidden, 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 missingitem: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.
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.
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
| Actor | On this page |
|---|---|
room-owner | registers rooms across the process; on one room instance — the per-instance admin interface: patches live config, kicks, locks, broadcasts, disposes |
entry-validator | accepts or rejects join requests with a code and a reason |
room-visitor | browses, joins with data, reconnects within grace window, leaves |
spectator | joins without contesting; receives broadcasts and live traffic |
match-organizer | reserves seats that count toward capacity; a reserve expires on the template's term (90s in battle) |
At a glance
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 longAttribute-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
[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.
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
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))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.
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.
| Declares | What it is |
|---|---|
capacity | in 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 |
visibility | enumerable · by name or code · hidden |
creation mode | one of three, and the on first join mode is obliged to declare an initialisation hook |
two independent timeouts | the 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 window | within it a return restores the same membership and the same seat, rather than making a new participant |
authority mode | our simulation or external authority, and there is no default |
trust in a reported outcome | for external authority: accept it · check it with a hook · do not accept it. Again no default |
behaviour when the host drops | wait out the grace window · close the room · admit a replacement |
map instance and world strata | optionally, which instance it occupies and which strata inside it |
room-scoped entities | which of the studio's entities have the room's scope — expressed by a predicate, not by a new mechanism |
The two machines.
| Of | States |
|---|---|
| a room | created → open → closed → torn down, where torn down is terminal and closed means no new joins rather than gone |
| a membership | active ⇄ inactive → departed, with departed terminal for that membership |
What is true of every room.
| Always | What it is |
|---|---|
losing a connection and leaving | are 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 replay | a reconnect resumes from the session state; the module does not promise the events of the gap |
a spectator | is 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 roles | a room owner is an actor holding a right (Access), not a rank in the member list |
presence | has a history, the roster does not: who joined, dropped, returned and departed is kept; the roster's changes are not a second history |
the interface | is a specific room's: you address this room, not only its type |
three axes, not two | the 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 |
The two authority modes.
| Mode | Who runs the tick |
|---|---|
| our simulation | our room implementation and its modules |
| external authority | a 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 line | is 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 identical | entry 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 move | no 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 outcome | is 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.
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()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.
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();| Always | What it is |
|---|---|
the handle | is 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) |
registration | takes 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 | |
|---|---|
entry | is 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 reservations | Matchmaking 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 leave | a 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 journal | a 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.
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.
| You mean | Read |
|---|---|
| which client receives which state, and how much of it | Visibility, with Data and Prediction |
| which machine owns the entity, and what happens when it dies | What Survives Losing a Host |
They are declared in two different places
Neither is configured at runtime, and they do not share a declaration.
| Declared on | Which names | |
|---|---|---|
| who sees what | the aspect — Data, Visibility | the visibility predicate, the object cap and its order, which neighbouring areas are visible, and the delivery mode |
| which machine runs it | the room type — Rooms | the 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.
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.
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.
| Broadcast | Per-actor packets | |
|---|---|---|
| Sends | the whole room, to everyone | each player only the slice their rules select |
| Suits | a small room; this is the default | a crowd, where packet size must stay predictable |
| Reads the declaration | once, for the room | per 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
| Actor | On this page |
|---|---|
schema-author | declares the visibility predicate, the object cap and its order, which neighbouring areas are visible, and the delivery mode |
any | subscribes and receives what the zone admits; may lower the object cap for itself within the declared bounds |
At a glance
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 scopeAttribute-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
[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.
| Declares | What it is |
|---|---|
predicate | the 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 order | a 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 areas | whether 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 mode | shared 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.
| Always | What it is |
|---|---|
visibility | is 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 recipient | may 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 |
truncation | is 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 |
degradation | is 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 shape | is 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
fnadm— 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.
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.
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.
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.
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
Joinalready cover it.
Who does what
| Actor | On this page |
|---|---|
player | creates and cancels their own ticket, and enters as part of a party |
match-organizer | declares matchmaker queues and relaxation; reads placement results |
backend-service | stamps trusted criteria pre-enqueue; runs external matchmaker decisions |
At a glance
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Part | What it is | Who believes it |
|---|---|---|
self-description | the participant's declared properties — rating, mode, language, chosen map | nobody without a check: it is the caller's claim |
requirement | a predicate the rest must satisfy | the 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.
| Declares | What it is |
|---|---|
properties | by name and type. A property not declared here is refused in a ticket as a validation failure rather than ignored |
roster size | a 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 ladder | an 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 language | the 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 |
mutuality | whether a roster is acceptable in which A accepts B while B does not accept A. There is no default |
ticket lifetime | after which the ticket transitions to expired with an event |
outcome | RoomPlacement — 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.
| Always | What it is |
|---|---|
one live ticket per participant per queue | a second is a conflict, not a second application — read the existing one |
the reason for a pairing | is 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 |
expiry | is 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 outcome | arrives 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 ticket | declared rather than inferred: a ticket is an application to play now, and matching an absent player makes the roster worse for everyone else |
matched | is 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
expiredwith 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.
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.
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
RandomPositionquery, no bypass. - Arenas should regenerate per match — a declared
Seedreproduces 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
| Actor | On this page |
|---|---|
schema-author | declares maps, obstacle primitives, destructibles, layers and their rules |
room-owner | binds a map to a room; requests spawn positions; raycasts |
operator | places or removes obstacles and layers from the panel |
At a glance
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 assetAttribute-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
[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 assetScatter 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 repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});The model
Two layers, declared by different people.
| Layer | What it holds, and who declares it |
|---|---|
static | terrain with height, obstacle primitives, bounds and locations — authored content |
dynamic | obstacles 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.
| Declares | What it is |
|---|---|
key and version | a 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 |
terrain | a 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 |
obstacles | a 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 obstacle | impassable · 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 |
bounds | the volume outside which a position is inadmissible |
world strata | declared spatial strata inside a map — ground, underground, air. These are geometry and addressing declarations |
locations | named places or areas — a spawn point, a capture zone, a corridor. A location answers where, never what happens: it carries no game logic |
placement generator | optionally, 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 stratum | a declaration inside the map — ground, underground, air |
map instance | an 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.
| Always | What it is |
|---|---|
one geometric canon | all geometry is in the platform's declared coordinate canon, and the precision of every geometric field is declared on the field |
the world model | is 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 moment | a 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 values | are 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 contact | are 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.
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.
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
| Actor | On this page |
|---|---|
room-owner | declares 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:
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
])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
[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.
| Declares | What it is |
|---|---|
shape | a 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 lives | on 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 checked | stepwise — 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 in | which volumes it is counted inside |
its relation to the art model | none 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:
| Response | What it means |
|---|---|
stop | movement ceases at the last admissible position |
slide | movement continues along the obstacle by whatever component is admissible |
bounce | the direction is reflected and the speed multiplied by a declared coefficient |
damp | movement continues with the speed multiplied by a declared fraction |
pass | the obstacle does not affect movement, but the contact is still observable |
cease to exist | the 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.
| Always | What it is |
|---|---|
every pair | has a response: a missing pair is a declaration defect, refused at deploy rather than met in combat |
the response table | is 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 platforms | the 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 |
simultaneity | is 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 fact | a 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 contact | before 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 module | moves nothing itself: it answers whether a position is admissible and what the response is; applying that is locomotion's |
a contact is an event | which 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.
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.
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:
Impulseknockbacks,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
| Actor | On this page |
|---|---|
schema-author | declares movement models, constraints and bindings |
room-owner | applies impulse, teleport and modifiers from the host |
player | submits 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
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)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
[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:
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateServer-side verbs:
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)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.
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.
| Declares | What it is |
|---|---|
movement model | one 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 |
parameters | declared 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 |
limits | maximum 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 input | stop, 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 tolerance | how 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 cap | how often the step runs, and how many steps may be taken in one go when the server is behind |
impulse kinds | each with its magnitude and its manner of decay |
What is true of every step.
| Always | What it is |
|---|---|
the module owns | an 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 |
authority | is the server's: under the our simulation mode a client sends an intent, never a result |
collisions | are not resolved here: it asks collision whether a position is admissible and what the response is, and keeps no response table of its own |
input | is an intent: "forward", "right", "turn the turret there" — accepted as it comes, because it asserts nothing about the world |
a claimed pose | is 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 sequencing | is required: the same sequence number is never applied twice, and a lower one is discarded |
a limit | clamps, 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 constraints | recoil, 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 bits | no bit-identical result across platforms is promised. What is promised is the same rules, and reproducibility within one authority |
the step | is 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.
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.
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:
ResolveAtrewinds 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
| Actor | On this page |
|---|---|
schema-author | declares predicted vs authoritative-only fields; sets prediction window |
room-owner | resolves hits at a historical state; rewinds the world |
player | predicts 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.
| Mechanism | What it does | Runs on | When it is wrong |
|---|---|---|---|
| Predicting your own motion | applies the declared model to your own input without waiting for the server | the client | a correction, replayed and smoothed |
| Showing other players | draws other entities between the states that arrive | the client | a visible jerk |
| Lag compensation | rewinds targets to the moment the shooter saw | the server | somebody 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.
| Preset | Predicts your own | Compensates | Smooths others |
|---|---|---|---|
| shooter | yes | in a window of roughly a second and a half | yes |
| arcade | yes | no | yes |
| observer | no | no | yes |
| no prediction | no | no | no — 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.
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 predictedAttribute-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
[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":
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
var arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareThe 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.
| Declares | What it is |
|---|---|
predictable aspects | which ones the client may step ahead of the authority |
divergence threshold | below 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 entities | interpolation between arrived states, or extrapolation |
interpolation delay | how far the display of others lags, declared rather than tuned by feel |
extrapolation window | beyond it an entity is marked stale and extrapolation ceases |
compensation window | how far back a rewind may reach, and it is managed |
what is rewound | positions and orientations of targets, and the geometry of dynamic obstacles if declared historical |
What is rewound, and what deliberately is not.
| What it is | |
|---|---|
rewound | the positions and orientations of targets, and the geometry of dynamic obstacles where the type declares them historical |
not rewound | life state — the dead do not revive in order to be shot — and ownership, score and inventory |
the rule behind the split | the decision is taken in the past; the effect is applied in the present |
| Always | What it is |
|---|---|
authoritative state names the input it saw | it carries the number of the last input applied, which is what makes reconciliation exact rather than approximate |
divergence | is observable: the client knows its prediction was corrected, instead of quietly drifting |
a view time | is 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 point | the same constraint as everywhere else in the contract |
one history ring, two consumers | reconciliation 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.
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
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:
- Accept the authoritative state.
- Replay the buffered inputs that came after the one it acknowledges.
- 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.
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
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.
| Mechanism | Runs on | When it is wrong |
|---|---|---|
| predicting your own | the client | a correction, replayed and smoothed |
| showing other players | the client | a visible jerk |
| lag compensation | the server | somebody 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.
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
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.
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.
When to use it
- Your lobbies need filling at off-peak hours —
FillRoomtops 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
ConnectAsBotlike 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
| Actor | On this page |
|---|---|
bot-brain | connects as a player; receives perception; sends commands |
room-owner | declares profiles, fills rooms to quota, hands over bot/human |
At a glance
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 inputsThe 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.
| Declares | What it is |
|---|---|
thinking tick | how 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 brains | where 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 preset | the bot's rights, as an ordinary actor preset |
visibility of the bot marker | whether 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 unavailable | one of three, with no default: do nothing · leave the room · fall back to built-in default behaviour |
roster filling | declared 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.
| Always | What it is |
|---|---|
an actor, not a player | it holds an actor credential but has no login provider, no links and no sessions |
economic ownership | is none — no entitlements, no purchases, no leaderboard entries — otherwise bots end up in the standings and in the economy |
perception | is 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 perception | is an actor preset, not a bot property: a debug or "omniscient coach" mode is declared as a preset with a wider predicate |
between thoughts | the 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 capacity | counts 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.
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 | |
|---|---|
| perception | exactly 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. |
| commands | exactly 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.
Not translated yet — showing English.
Auth & Players
Sign-in is an overridable step, not a black box. Providers, sessions, identity linking, bans. Every point in the flow — before and after sign-in, before and after a link, before and after a merge, on a status change — is a declared extension point with a declared kind: a gate that can refuse the step, or an observer that cannot.
When to use it
- Players must sign in — device, email, Apple, Google, Steam or custom — with create-on-first-sign-in as a flag, not a second flow.
- A guest account must upgrade later —
Linkadds Steam with progress intact, and merges reconcile two accounts into one player. - Policy must run where it can't be skipped — a region gate before sign-in, a starter pack after the sign-in that created the player.
- Moderation needs teeth — revoke sessions, suspend, device-ban, with a
bannedevent 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
authcannot be switched off while any of them needs a player actor: the module configurator refuses, and names the dependants.
Who does what
| Actor | On this page |
|---|---|
player | signs in, links or unlinks identities, refreshes, logs out |
moderator | revokes sessions; bans, suspends or restores players |
backend-service | gates sign-in by region; seeds a new player's first rows; reads and revokes sessions |
At a glance
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesEach point is customised where it is declared; the shapes a handler can take are collected in Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
The declared form is what the panel renders: every point shows its handlers, their kind and the resolved order. The kind is the part with teeth:
| Kind | When the handler itself fails | On refusal |
|---|---|---|
| a gate | the step is refused — an unreachable region check is not a passed region check | a code from the platform's catalogue plus a human reason. Callers branch on the code; the reason text is free to change and to be translated |
| an observer | the step stays done, so a starter pack that did not land costs a chest, not the sign-in | it cannot refuse |
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
A player is a bearer of identity, not a row in your schema, and their identifier is stable and never reused — including on a merge: a merged player's id keeps resolving rather than becoming a dangling reference. A link is a triple: provider · external subject · player.
| Always | What it is |
|---|---|
provider + subject | is unique, and that uniqueness is a source of a conflict, not a prohibition — the answer to "this account is already taken" is to choose a merge, not to be told no |
at most one link per provider per player | a second account of the same provider is a conflict |
an external subject | is never a player's identifier: it belongs to the provider, and using it as ours would tie our ids to theirs |
identity kind and access status | are different axes: anonymous versus registered is one; active / suspended / banned is another. Conflating them makes "banned anonymous" or "suspended registered" inexpressible |
a session and a credential | are different things: a session is the record; a credential is what you present. Revoking a session invalidates all its credentials and closes its open subscriptions |
many simultaneous sessions | each revoked independently |
a credential's claims | are declared context — region, locale — and context only. A claim never carries authority |
a device fingerprint | is not an identity: it is never ground to admit, only ground to refuse, and it is stored and compared in an irreversible form |
The three machines.
| Of | States |
|---|---|
| the kind of identity | anonymous → registered, and the transition is one-way |
| the access status | active ⇄ suspended, and active → banned → active for an unban |
| the player | alive → merged, where merged is terminal: a merged player does not sign in again |
What the consumer declares.
| Declares | What it is |
|---|---|
sign-in policy | whether anonymous sign-in is allowed, and the rest of the rules around signing in. Declared as an attribute on the module's mount point — not a config file beside the code, and not constructed at runtime |
default role | the set a new player carries at first sign-in. There is no default for the default: declare nothing and new players arrive with no roles, which is a legitimate declaration rather than an omission |
session policy | what happens when the simultaneous-session ceiling is reached — evict the oldest with an event, or refuse the new one. No default |
deletion policy | how a player's deletion reaches the data that references them |
Where a provider is configured. In the operator plane, not in code — a store credential does not belong in a repository. What is declared reaches the admin console for reading.
What granting a role does. Roles are not only an operator's business: the surface carries grant and revoke for a player, so a game can promote a guild officer or hand a tournament host their powers from its own code.
| Always | What it is |
|---|---|
it is not self-promotion | granting requires the declared permission atom for it, and an actor without that atom gets a plain forbidden rather than a silent no-op |
granting is idempotent | granting a role the player already holds is a success, not a conflict: the state is the set of roles, not the history of calls, so unlike sign-in this operation needs no idempotency key |
revoking is not instant | and 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 role | is 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 —
mergedis terminal. - The provider being unavailable answers unavailable and is worth retrying with backoff; the provider rejecting the credential answers not authenticated and is worth one retry, not a loop. 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.
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.
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_idand 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
| Actor | On this page |
|---|---|
schema-author | marks entities as player-owned and declares which of them form the profile |
player | reads their own profile; writes go to the entities themselves |
room-visitor | reads 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-sideAttribute-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
[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.
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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 leavesThe model
| Concept | What it is |
|---|---|
player_id | the platform's whole idea of a player, plus the system profile behind it — Auth |
| profile set | the player-owned entities the project declares as its profile; declaring it is optional |
| owner selection | the read: one owner in, their rows across the set out — the same rights, predicates, filters and subscription as any Entity selection |
| public read | that selection against another owner, narrowed by the reading role's row predicate and column mask (Access) |
What is true of every profile read.
| Always | What it is |
|---|---|
there is no profile record | it 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 lives | patch the progress row, and every profile read that includes it sees the new value on its next pass |
there is no public write | a view has nothing to write to, and shared-writable state goes through server code |
declaring the set is optional | and 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 predicate | not 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 hook | a 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 —
fnoradm. 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.
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.
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
| Actor | Can | Cannot |
|---|---|---|
player | propose 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 request | read anyone else's list of relationships, under any participant relationship whatsoever |
moderator | decide on invitations and join requests where they hold the membership-administration atom | decide on an intent they hold no permission for — that answers forbidden |
The model
What a relationship declaration carries.
| Declares | What it is |
|---|---|
kind | symmetric — 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 rule | after a refusal: forbidden · permitted after a declared period · permitted at once. Declared, because "ask again" is a product decision |
presence visibility | a 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 broken | after 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.
| State | Meaning |
|---|---|
proposed | the initiator proposed and the other side has not answered |
mutual | both sides agree |
declined | the other side refused. The relationship is kept, because the re-invitation rule needs to know |
broken | one side left a mutual relationship |
blocked | one side blocked the other |
What is true of every relationship.
| Always | What it is |
|---|---|
one entity per pair | not two mirrored records. "A proposed to B" and "B was proposed to by A" are one fact read from two sides |
an initiator | is declared: who proposed, which display and the re-invitation rule both need |
blocked dominates | from it there is no transition to proposed or mutual |
a block | is asymmetric in control, symmetric in effect: only the one who set it may lift it, and it acts both ways |
a refusal on a block | does not reveal it: the operation answers not found, so a blocked actor cannot discover the block by probing |
the block state | is owned here and consumed elsewhere: messaging and others read it; none of them mutates it, and none keeps a copy |
presence | is derived from sessions: not written by anybody, and the visibility predicate is applied per requester rather than once per actor |
a deferred intent | does 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 group | keeps 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 overwrites | relationships 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
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.
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
| Actor | On this page |
|---|---|
player | sends and receives messages; reads history; mutes or blocks |
moderator | filters, redacts and bans terms |
backend-service | sends or schedules templated notifications |
At a glance
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)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.
// 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")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.
[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:
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declares | What it is |
|---|---|
the group | its roster — a participant is an actor, exactly as in groups |
binding to a lifetime | optionally another entity's, so a room chat disappears with its room |
Where the line runs between the envelope and the payload.
| Part | Whose it is |
|---|---|
envelope | the platform's: the author, the conversation, the moment by the declared clock |
payload | the 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.
| Of | States |
|---|---|
| a conversation | created → active → closed |
| a message | sent → published | rejected by the filter, and then edited or deleted, observably |
| a notification | created → queued → delivered | expired |
What is true of every message.
| Always | What it is |
|---|---|
order within a conversation | is stable and declared. Order between conversations is not promised |
editing and deleting | are observable: a message never vanishes silently — otherwise a client's history and the server's diverge with nobody knowing |
history | is the messages themselves: with a declared retention period, read in pages by cursor from a position |
retention outlives the complaint window | the 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 state | is 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 |
sending | is idempotent by key: two calls are two lines of dialogue, so the key is what makes a retry safe |
blocking | is 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 payload | the 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 route | is not part of the contract: push, in-app, or something else is a routing decision, not a promise |
delivery | is 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.
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.
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
Grantwith originreward(Leaderboards cycle chests arrive that way), so even a shopless game keeps a single auditable grant ledger.
Who does what
| Actor | On this page |
|---|---|
player | browses storefronts, purchases, manages wallet, redeems codes |
seller | configures catalog, prices and storefront schedules |
backend-service | validates receipts; reprices or grants via purchase hooks |
At a glance
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declares | What it is |
|---|---|
key | it is authored content, addressed by a key so a rename in code is a rename |
kind | consumable — it is spent; or durable — owned once |
prices | a 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 provider | one slot per provider, declared, because a store knows the item by its own id |
what it points at | optionally 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 purchasable | a 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 third | a 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 price | is expressed by a storefront rather than on the item |
What a storefront declares.
| Declares | What it is |
|---|---|
offers | the set, each pointing at an item |
schedule | in wall-clock time, always UTC: when the window opens and closes |
audience | a 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.
| From | To |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid or granted | refunded |
| Always | What it is |
|---|---|
the price | is fixed in the order at the moment it is created, so a price change afterwards cannot alter what was agreed |
awaiting payment | has a declared deadline, declared per provider, because they differ |
granting | is 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 refund | is 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 entitlement | carries its origin — a purchase, a promo code, a reward, a gift — so "where did this come from" is answerable a year later |
a consumable entitlement | accumulates: it changes by an increment with an idempotency key, never by overwriting what was read |
ownership | is an owner predicate: an entitlement belongs to a player by the same mechanism as any owned row |
the catalog | is 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 secrets | live in the operator plane, never in the declaration, and never in a repository |
a provider's capabilities | are 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.
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.
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
| Actor | On this page |
|---|---|
player | reads their own holdings and spends from them |
backend-service | grants, increments and revokes on a player's behalf, naming the player it acts for |
At a glance
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)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.
| Declares | What it is |
|---|---|
| an owned type | the holding belongs to an owner, and selection by owner is entity's own operation |
a ref to a catalog item | the 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 increment | a 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 behaviour | one of three, and there is no default: refuse · redirect into a declared owner bucket · discard with event |
What is true of every holding.
| Always | What it is |
|---|---|
the cap has no default | the 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 immutable | nothing 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 players | is a different promise: it needs escrow and anti-fraud, and it is outside this version |
a row | displays 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.
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.
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
| Actor | On this page |
|---|---|
player | reads top-N/around-me/own rank, subscribes to rank changes |
backend-service | submits results; corrects or rejects them in the pre-submit hook; grants rewards when a cycle closes |
operator | declares boards; closes a cycle early, corrects records (audited), watches submission rates |
At a glance
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 itAttribute-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
[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:
Agg | A second submit | Idempotent |
|---|---|---|
Set | replaces the record with the submitted values | yes |
Best | replaces it only when the new values rank higher under the order key | yes |
Increment | adds the submitted values to the record — kills, laps, guild contribution | no — carry an idempotency key |
Decrement | subtracts them | no — 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:
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 call | What it is |
|---|---|
PlayServ · playserv | the 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 submitter | the 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 |
playerId | the 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:
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("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.
| Axis | Values | How you set it |
|---|---|---|
| Owner | player · group | Owner = Owner.Player — a guild board is the same board with Owner.Group |
| Order key | one or more declared fields, each ascending or descending | [Rank(1, Sort.Descending)] int Score |
| Aggregation | set · best · increment · decrement | Agg = Aggregation.Best |
| Reset | a schedule in UTC; a cycle expires, never deletes | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Who may submit | server only (the default) · players | Submit = Submit.ServerOnly |
| Display fields | declared and typed; never part of the order | [Display] string Map |
| Owner list | chosen at read time, not declared | ForOwners("weekly-score", ids) — friends, a guild, a lobby |
| Tournament rules | entry window · max entrants · attempts per cycle · join-required | Rules = 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.
| Always | What it is |
|---|---|
direction and operator | are 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 generation | a second is not a second row |
an entry | is 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 order | they are display, and that is why they are declared separately |
a generation | expires, it does not delete: open → expired → evicted from retention, and expired generations stay readable for the declared retention period |
the schedule transition | is observable by an event, so a handler reads exactly the table that closed rather than the empty one that just opened |
the default submitter | is the server: who may submit is declared, and the default is not the player |
a board | is 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 |
What a cycle is, and what closing one does.
| What it is | |
|---|---|
a reset | closes a cycle rather than deleting it |
a closed cycle | stops taking submits and stays readable under its label — Top("weekly-score", 100, cycle: label), a read parameter rather than an export job |
the close event | carries 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.
| Hook | What it may do |
|---|---|
pre-submit | a 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-closed | an observer: fired after the fact, it cannot veto, and a failure there leaves the cycle closed |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
| Constraint | Declared as | At the boundary |
|---|---|---|
| entry window | entryWindow: TimeSpan — how long joining stays open after the cycle opens | a join after it closes is refused; the cycle still runs to its reset |
| max entrants | maxEntrants: int — records in one cycle | entrant 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 cycle | attemptsPerCycle: int — submits per owner | the next submit answers "attempts exhausted" — a conflict, not a permission error, and the counter resets with the cycle |
| join-required | joinRequired: true — entrants are a membership, not everyone who plays | a 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.
| Limit | At the boundary | Number |
|---|---|---|
| rows per read | the page is trimmed, "there is more" stays true, after: continues | page cap set per project |
| window around an owner | trimmed symmetrically | window cap set per project |
| records in one cycle | the submit is refused as a conflict; no eviction | maxEntrants per board; unbounded when unset |
| attempts per owner per cycle | conflict "attempts exhausted", cleared by the reset | attemptsPerCycle per board; unbounded when unset |
| submit rate per owner | rate-limit refusal carrying the moment a retry is allowed | rate set per project |
| boards per project | a new declaration is refused at deploy | limit set per project |
| retention of closed cycles | the cycle leaves storage with an event; reads then answer not-found | retention 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.
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.
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
| Actor | On this page |
|---|---|
player | uploads chunks, reads streamed files, submits UGC |
moderator | reviews the queue, approves or rejects submissions |
backend-service | derives asset variants; hooks upload and moderation; sets quotas |
At a glance
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clThe gates around it are hooks, same contract as everywhere:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declares | What it is |
|---|---|
origin | authored content, game-generated, or user-generated — and the size limits and policies follow from it |
admissible content types | as a declared list, never sniffed from the bytes |
size limits | checked when the session is opened, by the declared size, rather than on the last part |
part size and order | the upload is performed by a session: a declared part size, the order of parts, a resumption point |
derivatives | optionally, 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 prefix | on 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.
| Always | What it is |
|---|---|
completion | is idempotent by session: a repeat completion returns the same file rather than a second one |
a checksum | is obligatory, and a mismatch is a refusal, never a silent acceptance of corrupted bytes |
a published file | is immutable: an edit is a new version, and a reference to a version keeps pointing at what it pointed at |
authored content | is addressed by key plus version, and it is managed: not edited in the admin console, because the code owns it |
an unfinished session | dies observably: past its deadline it is terminated with an event and its parts are freed |
ownership | follows 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 access | may 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, notforbidden— 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 —
expiredwith 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.
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.
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
| Actor | Can | Cannot |
|---|---|---|
any actor | declare types in schema; emit on its own behalf, one at a time or as a batch; read the declared types | fill in the context; read, query or aggregate what was emitted |
backend-service | the same, and emit on behalf of a player by delegation | read telemetry — there is no read permission, because there is no read operation |
At a glance
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))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.
[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.
| Declares | What it is |
|---|---|
name | the type's own |
fields | typed by the platform's type system; a field mask applies to them as everywhere else |
schema version | mandatory, 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 |
sampling | what 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 tolerance | whether this type tolerates loss. Telemetry is the only place in the contract where declared loss is lawful |
deletion behaviour | how 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.
| Always | What it is |
|---|---|
no addressing target | no recipient, no group, no subscription. Wanting to address one is the sign that a game event is what is needed |
context | is 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 share | travels with the event: without it the absolute number cannot be reconstructed from what arrived |
loss | is 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 |
emission | is 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 events | are 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.
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 plane | The operator plane | |
|---|---|---|
| Reached from | game code | the Control Panel, the CLI, MCP |
| Holds | rooms · entities · players · commerce · leaderboards | projects & environments · deploy and rollback · billing · org and user administration · cluster routing |
| Works in | declarations, hooks, events, operations | the 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 surface | The operator sees |
|---|---|
| Schema / Data | entities, migrations, the record browser, saved views, import/export |
| Entity | state machines, per-instance inspection |
| Entity Presets | drop tables, ability, stat and projectile definitions, world-object presets — live-tunable |
| Access | the role grid: roles × operations, row filters, column masks |
| Extensibility | scenario chains with overrides, resolved order, invocation traces |
| Rooms | the fleet: rooms, tick health, placement, drain status |
| Matchmaking | queues, tickets in flight, relaxation curves |
| Commerce | catalog, storefront scheduling, receipts, refunds |
| Leaderboards | cycles, record correction (audited), submission rates |
| Auth | providers, sessions, bans, the auth scenario |
| Files | assets, UGC review queues, quotas |
| Analytics | dashboards, forwarders, ingestion lag |
| Map | maps and obstacle sets, live instances |
| Visibility / Collision / Locomotion / Prediction | per-room tuning: rules, response pairs, windows, per-actor packet cost |
| Groups / Messaging | group browser, templates, moderation filters, schedules |
| Bots | profiles, fill quotas, brain endpoints |
| Inventory / Profile | holdings 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.
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
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:
| Axis | Range |
|---|---|
| Shape | message-driven or request-driven |
| Channels | single-channel or multi-channel |
| State | with connection state recovery, or without |
| Protocol | TCP 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:
| Level | Meaning |
|---|---|
at least once | redelivered until acknowledged; the receiver tolerates duplicates |
at most once | sent once, never retried; loss is acceptable |
exactly once | deduplicated 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:
- Disable the dependent chain. Every module that needs the missing one is switched off too, and its interfaces are absent rather than failing.
- 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.
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.
La simulación no es tu código
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.
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
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:
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)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
[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 bloque | Viene de |
|---|---|
Vector3, Stat | el paquete core de tu binding |
Body, y las formas de cuerpo | Collision |
Motion, y los cinco modelos de movimiento | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | los entity presets que los usan |
EntryRequest, Verdict, StatEvent | payloads de Hook, entregados por el módulo al que te enganchas |
Seat | Matchmaking |
Scope, los alcances de sincronización | Visibility |
Tick, las tasas de Tick | Rooms |
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:
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)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
[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:
| Clave | Qué nombra |
|---|---|
rock | un 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, shell | las 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:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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ínea | Qué la ejecuta realmente |
|---|---|
se dispara Stats.Depleted | el Stat llegando a su piso — 0 para Hp, ya que la Declaration solo fijó Max |
| la transición de muerte | AtMin = "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 |
RollAt | generado sobre la Declaration [DropTable] — por eso es partial, y por eso la pestaña de Go dice drops.RollAtCrateLoot |
| el pickup | pasar 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):
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)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.
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:
| Llamada | Con qué responde |
|---|---|
Find | un 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 |
Join | resuelve 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 |
Cast | disparar 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) |
Abilities | generado — 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 frontera | Qué recibe quien llama |
|---|---|
| una entrada por encima de la capacidad, o a una Room cerrada | conflict — vale reintentar cuando se libere un asiento |
| creación de Room por encima del límite por Project o por Actor | rechazada, y nada de lo ya creado se descarta |
| crear Rooms o hacer sign-in demasiado rápido | un rechazo por límite de tasa que lleva el tiempo de espera |
| un payload de Event por encima del tope de la Room | rechazado antes de enviarse, nunca truncado |
| una lectura por encima del techo de filas de un rol | las 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
- 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.
- 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).
- 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ú eres | Lee, 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 Unreal | Rooms (Hospedar una Room) → Bots → Locomotion · World Objects → Map → What Survives Losing a Host |
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.
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)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
[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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
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))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.
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.
Cajas de vida en Tanks
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.
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)])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
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.
[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)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
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 healthcon 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.
Adjustemitechanged; 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
| Antes | Después | |
|---|---|---|
| un tanque dañado | queda dañado hasta que muere | puede recuperarse recorriendo la arena |
| código de Room | ninguno | sigue 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
entityy 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.
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.
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)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
[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.
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)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.
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í.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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
- Leaderboards — los ejes de torneo (ventana de inscripción, máximo de inscritos, intentos).
- Matchmaking → Rooms — parties, colocación y siembra.
- Extensibility → Commerce → Messaging — la cadena de Hooks que paga.
- Core Concepts — el vocabulario que da por supuesto cada página de módulo.
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.
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:
| Superficie | Significado |
|---|---|
| Declarations | qué existe y cómo se comporta, escrito en código o en el panel administrativo; el mismo modelo de cualquiera de las dos formas |
| Hooks | tus reglas, llamadas por la plataforma en pasos con nombre; desplegadas como funciones en la nube |
| Events | lo que la plataforma te dice que pasó — suscríbete, no consultes en bucle |
| Operations | lo 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:
| Etiqueta | Superficie |
|---|---|
fn | Función en la nube (C# · TypeScript · Python · Go). Autoritativa del servidor; el hogar principal de tus reglas |
cl | Cliente de juego (C++ de Unreal / C# de Unity). API simétrica; los roles desbloquean menos |
mc | La 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 |
adm | Panel 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.
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
| Pregunta | Respuestas |
|---|---|
| ¿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 nube | cliente de juego | host de Room | admin | |
|---|---|---|---|---|
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.
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.
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 —
CanIevalú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é
| Actor | En esta página |
|---|---|
operator | declara roles y políticas, fija límites de fila/columna, otorga roles, emite claves |
match-organizer | el personal de torneo del flujo de abajo: lleva una clave compuesta, controla las entradas, no puede reembolsar |
every actor | comprueba CanI antes de actuar; ve solo las interfaces que tiene desbloqueadas |
De un vistazo
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()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érmino | Qué es |
|---|---|
atom | un 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) |
role | un 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 preset | se 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 predicate | qué filas — un predicado booleano sobre valores de la sesión |
field mask | qué 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.
| Credencial | Qué desbloquea |
|---|---|
player key | la 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 key | la lleva un servidor dedicado o un master-client, y sus roles desbloquean las filas mc |
pushed code | corre con el rol backend-service del Project — eso es lo que comprueba el Authoritative = true de un leaderboard |
a registered hook | no 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.
| Siempre | Qué es |
|---|---|
a credential | no 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 |
delegation | cambia 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 verb | responde 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 row | responde not found: un rechazo no debe convertirse en un oráculo de existencia |
an owner | siempre se ve a sí mismo, diga lo que diga cualquier otro predicado |
visibility | no es seguridad — una optimización de canal y un permiso son mecanismos distintos, y ninguno sustituye al otro |
a disabled module | no 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 surface | sigue 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 |
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, noforbidden— 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
forbiddendonde 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.
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
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.
| Nivel | Qué vive aquí |
|---|---|
| Los principios comunes | Idé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 lenguaje | Solo 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 motor | Solo 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.
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
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.
// 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, idempotentAttribute-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.
// 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, idempotentApagar 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.
// 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 downAttribute-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.
// 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 quieres | Qué es |
|---|---|
| deja de entregarme | local. Siempre tiene éxito, incluso con la conexión caída. Liberar una suscripción es esto. |
| detén el trabajo | una 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
- Getting Started — la primera Room, de punta a punta.
- How the SDK Is Built — por qué hay una sola superficie y cómo encajan las piezas.
- Under the Hood — la capa por debajo de esta, si te da curiosidad.
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.
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
Problemtipado 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é
| Actor | En esta página |
|---|---|
any actor | lee identidad, contexto y roles mediante Whoami |
backend-service | actúa como un jugador; agrupa en lotes operaciones idempotentes |
operator | lee trazas de llamadas fallidas o reintentadas |
De un vistazo
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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.
| Campo | Qué es |
|---|---|
code | el 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 |
category | la clase a la que pertenece el rechazo, que es la que dice si un reintento tiene sentido siquiera |
trace identifier | el identificador de esta ocurrencia en concreto, presente siempre, errores locales incluidos, de modo que contactar a soporte nunca exige reproducir la falla primero |
explanation | texto 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 errors | la 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.
| Origen | Qué pasó |
|---|---|
platform | respondió con un rechazo, llevando un código del catálogo de la plataforma |
local | el SDK rechazó antes de enviar, desde su propio vocabulario publicado |
unknown | la llamada se envió y no volvió respuesta. Ni «la plataforma dijo que no» ni «nunca preguntamos» |
Qué vale para todo rechazo.
| Siempre | Qué es |
|---|---|
a refused operation applied nothing | la 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 path | no 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 timeout | no 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
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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.
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.
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.yon.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é
| Actor | En esta página |
|---|---|
schema-author | declara Events con [Event], envía el schema |
any actor | emite mediante send., se suscribe mediante on. |
De un vistazo
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))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.
| Declara | Qué es |
|---|---|
name | un nombre de cable explícito, declarado en vez de derivado del símbolo |
payload | el schema de lo que lleva una emisión |
target | adó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 |
clock | sim_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 |
retention | transient — 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 |
term | en un tipo retained: cuánto se conserva, y qué pasa al vencer. «Para siempre» no es uno de los valores |
delivery | a 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 |
context | el 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.
| Campo | Qué es |
|---|---|
type | el 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 |
payload | conforme al schema del tipo |
source | el 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 |
stamp | en el reloj declarado del tipo |
dedup key | presente 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 key | en 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.
| Sostiene | Qué es |
|---|---|
event | el tipo declarado al que está ligada |
surface | el nodo sobre el que se toma, dentro del target declarado del tipo — la mitad de la audiencia que le toca al suscriptor |
handler | tipado al payload |
position | desde 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.
| Siempre | Qué es |
|---|---|
audience | nunca 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 |
phases | emitido, luego entregado — y nada más. Un Event no tiene máquina de estados: ocurre una vez |
ordering | prometido 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 streams | cuando 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 detection | donde 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.
| Transitorio | Retenido | |
|---|---|---|
| Llega a | quien esté suscrito en ese momento | eso, y a un suscriptor que llegue después |
| Después | se fue | se conserva por un plazo declarado |
| Legible de vuelta | no | sí, a lo largo del plazo |
| Pasado el plazo | — | una selección rechaza, en vez de responder vacío |
[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)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
Problemtipado — 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.
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.
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é
| Actor | En esta página |
|---|---|
schema-author | declara los RPC, sus modos y quién puede llamarlos |
any actor | invoca una llamada con respuesta o de vía única, donde la Declaration lo permite |
group member | responde a una llamada repartida; vuelve una respuesta por miembro |
De un vistazo
[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)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.
| Declara | Qué es |
|---|---|
name | del vocabulario de verbos |
input | los argumentos que quien llama debe elegir |
output | exactamente 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 mode | with 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 mode | immediate — 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 |
streaming | si la entrada y la salida llegan por partes y se manejan a medida que llegan, en vez de como un todo |
idempotency | un RPC de vía única también lleva una clave de idempotencia: que no haya respuesta no quiere decir que no haya reentrega |
overridability | declarada en el propio método. Sin Declaration significa no sobrescribible — nunca sobrescribible por defecto |
context | dó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.
| Lleva | Qué es |
|---|---|
arguments | solo lo que quien llama debe elegir |
implicit context | el 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 |
references | un 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 |
outcome | un valor del tipo de salida declarado, o un Problem tipado |
Qué lleva el descriptor de una llamada diferida.
| Sostiene | Qué es |
|---|---|
state | accepted → running → completed o failed, los dos últimos terminales |
lifetime | declarado; pasado él el desenlace no está disponible y pedirlo es un rechazo, no una respuesta vacía |
cancel | idempotente, 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.
| Siempre | Qué es |
|---|---|
one handler | exactamente 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ó |
meaning | una 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 machine | una 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 stream | no 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 write | ninguna 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.
[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 ranexport 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 ranAttribute-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 ranErrores
- 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.
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.
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é
| Actor | En esta página |
|---|---|
schema-author | declara los aspectos, su política de sincronización y el predicado de visibilidad |
any actor | se suscribe a un destino; retoma desde una posición; pide el estado completo |
backend-service | Hooks antes y después del cambio |
operator | lee el costo de paquete por Actor; ve cuándo se degrada la entrega o se corta un paquete |
De un vistazo
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 consequenceEl modelo
Qué lleva un Delta.
| Campo | Qué es |
|---|---|
changed fields | solo esos, nunca el objeto entero |
pair | el par instancia × aspecto al que pertenece |
number | un 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.
| Declara | Valores, y qué no es |
|---|---|
priority | ordena 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 rate | una cota superior sobre el envío. No es una promesa de recibir a esa tasa — recibir depende del canal |
delta only | no enviar lo que no ha cambiado |
delivery mode | shared 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 rule | el predicado que decide quién recibe siquiera — Visibility proyecta esa mitad por completo |
Qué lleva una suscripción.
| Sostiene | Qué es |
|---|---|
target | una 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 |
position | desde 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 |
state | active → gap detected → resynchronised | closed, y closed es terminal |
Qué vale para todo Stream.
| Siempre | Qué es |
|---|---|
merging | los 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 detection | perder un Delta en silencio está prohibido; el número de secuencia del par es lo que cuenta el consumidor |
ordering | se cumple dentro de un par instancia × aspecto; entre pares no se promete de ninguna forma |
traversal | es 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 |
history | se 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 budget | se 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.
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | Qué puede hacer |
|---|---|
| antes de un cambio | mutarlo, 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 cambio | agregar 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
closedde 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.
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.
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é
| Actor | En esta página |
|---|---|
player | crea 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-owner | las reglas de asientos de una Room van montadas sobre este Primitive (se configuran en Rooms) |
backend-service | declara los tipos de Group y sus reglas; Hooks en la entrada y en la salida |
De un vistazo
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 groupAttribute-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 groupEl 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.
[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: ...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() };
[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.
| Declara | Qué es |
|---|---|
name | el del propio tipo |
membership mode | explicit — 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 |
rule | para 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 |
capacity | y el comportamiento al alcanzarla |
entry rule | un predicado que puede rechazar la entrada, aparte de un Hook que también puede rechazarla |
lifecycle behaviour | en 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 |
lifetime | opcional: al vencer, el Group se cierra con un Event |
Qué vale para todo Group.
| Siempre | Qué es |
|---|---|
member | un Actor, nunca una Entity: un conjunto de Entities es una selección sobre Data. Un Group es un solo oyente masivo |
states | created → active → closed, y closed es terminal. Una instancia de Group tiene máquina; el tipo no la declara |
event target | emite 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 call | son 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 outcome | nunca 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 |
recipients | nunca 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 roles | no 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 exit | son 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 |
recomputation | lleva 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 leave | son 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 interface | es la de un Group concreto, no solo la del tipo: te diriges a este escuadrón |
the primitive | se 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.
AddoRemovesobre 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 = 4arriba); 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.
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.
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/Afterordenado 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é
| Actor | En esta página |
|---|---|
backend-service | sobrescribe eslabones, envuelve pasos con middleware, escribe manejadores de trigger |
operator | inspecciona cadenas, fija el orden, lee secretos, simula la resolución |
De un vistazo
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
El modelo
A qué te da la plataforma para engancharte.
| Término | Qué es |
|---|---|
registered function | un paso sobrescribible de la plataforma — «crear perfil», «resolver precio» |
scenario | la cadena ordenada que ejecuta un flujo de la plataforma: auth, entrada, compra |
overridability | si un eslabón puede reemplazarse, solo envolverse, o es fijo |
middleware | un manejador ordenado previo/posterior alrededor de un eslabón |
trigger | lo que arranca tu código: un Event, un horario, un webhook |
secret | un valor que tu manejador puede leer |
invocation | una ejecución, con su traza |
Qué declara un Hook.
| Declara | Qué es |
|---|---|
position | el paso con nombre al que se engancha |
kind | gatekeeper — 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 |
moment | before — 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 |
effect | lo 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 condition | un 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 |
| Siempre | Qué es |
|---|---|
all three | se despliegan con playserv push |
the two attribute shapes | son lo que dibuja el panel, porque la Declaration lleva el nombre del paso o del eslabón al modelo enviado |
the middleware form | lleva 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 link | deja 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.
| Siempre | Qué es |
|---|---|
four directions | una función en la nube · el backend externo del consumidor · el servidor de juego · otro declarado |
the router | va dirigido por mensajes/señales; request-response es un adaptador sobre él y no su naturaleza |
matching | es 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 twice | es un defecto de la Declaration, rechazado cuando se declara el conjunto en vez de resolverse en el momento de la llamada |
an unregistered name | responde not found, en vez de caerse en silencio |
the direction | no 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 RPCs | se registran en el mismo enrutador: declarar uno es registrarlo, y no hay una segunda manera |
ordering | ejecuta 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 constraint | un 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 effect | queda 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 violation | es 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 checked | en 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 time | eso sería una segunda respuesta a una pregunta zanjada, hecha en el único momento en que no se puede hacer nada al respecto |
| Siempre | Qué es |
|---|---|
handlers | son 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 sees | los 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 |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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-openofail-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.
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.
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.
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 codegenregenera 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é
| Actor | En esta página |
|---|---|
schema-author | declara Entities/parts/enums en código, hace diff y push |
operator | revisa el resumen del panel, propone y aplica migraciones |
ci | la 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
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 = 0Attribute-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
[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.
| Lleva | Qué es |
|---|---|
key | el nombre estable por el que se la direcciona. Un renombrado en código es un renombrado, no un borrar-y-crear |
kind | una Entity, una part o un enum, declarados en código |
ownership mode | seed — 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 |
preset | opcionalmente: 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.
| Siempre | Qué es |
|---|---|
matching | es por clave, nunca por símbolo: un push repetido tras renombrar el símbolo deja un registro y no dos |
idempotency | se sigue de eso — un push repetido no es un segundo registro |
the report | dice exactamente qué va a cambiar antes de aplicar, y qué sobrescribió después |
origin | es distinguible: un registro creado por un push desde código se diferencia de uno creado en otro lado |
the revision | viaja con él, y un push aterriza entero o no aterriza |
Qué promete la generación de código.
| Siempre | Qué es |
|---|---|
regeneration | ocurre después de cada push, y los tipos generados nunca se editan a mano: regenerar y luego hacer diff no produce cambio alguno |
naming | sigue 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 directions | no 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.
| Siempre | Qué es |
|---|---|
when one is required | un cambio de Declaration persistente que reescribe valores existentes, y un cambio persistente rompedor no puede publicarse sin una |
what it declares | una versión, una vista previa, una aplicación ordenada, un rollback ante fallo, y un desenlace de finalización observable |
coexistence | mientras 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
forbiddeny el modelo desplegado queda intacto: un rechazo nunca es un push parcial. Ese es un rechazo distinto de una Revision rancia, que esprecondition_failedy 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.
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.
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_timepasado, 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é
| Actor | En esta página |
|---|---|
schema-author | declara Entities, aspectos, máquinas de estados, presets |
every actor | consulta, se suscribe, llama RPC de Entity, lee estado |
De un vistazo
[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()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();
};
[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.blockedse asocia conopenpor sí solo, así que la transiciónclose_requesteddeclarada enopenaplica dentro de él sin repetirse. Mientras la máquina está enopen.blockedestá enopen— una comprobación de estado paraopenes verdadera, yOnEntered("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
openingdescarta suAfterSeconds, 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 Declaration | Qué significa |
|---|---|
caller in entity.room | cualquier 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 |
Actor | la identidad de quien llama, el mismo objeto que devuelve whoami |
caller.Inventory | el 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:
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"))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.
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
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.
| Declara | Qué es |
|---|---|
aspect | un 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 machine | estados anidables un nivel, transiciones y guardas; varias por Entity |
trigger | qué dispara una transición — las cuatro fuentes están abajo |
entity RPC | un verbo que sobresale de la Entity, declarado dentro de la vista con el átomo de derecho que necesita |
entity event | una señal que la Entity emite, entregada a quien se suscriba a esa instancia |
hook | previo 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 track | si la vista conserva siquiera la ventana instantánea |
ref | un 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.
| Fuente | Cómo se dispara |
|---|---|
client event or RPC | cualquiera declarado — el RequestOpen de arriba dispara open_requested |
collision | un contacto o la entrada a un volumen disparador — trampas, placas de presión — a través del aspecto al que liga Collision |
data threshold | declarado sobre un Stat, 0 HP → death, aplicado por el orden de Hooks y no por código en una Room |
time | AfterSeconds 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.
| Eje | Qué es admisible |
|---|---|
filter y sort | solo campos declarados — no hay handle de tabla, y una selección se direcciona por Entity, acotada a una Room o al Project |
include | una ref declarada, traída junto con la página |
paging | por 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 |
access | los predicados se aplican antes de paginar, así que una página nunca lleva huecos donde estarían las filas ocultas |
live | suscribirse a una selección la mantiene viva, con miembros que entran y salen a medida que cambian sus datos |
// 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()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.
// 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.
| Siempre | Qué es |
|---|---|
a selection | es 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 request | sigue 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 guard | es una operación distinta con un átomo distinto — entity × administer, que ninguna clave de cliente ostenta por defecto |
history | es 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 change | es 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 implementation | es 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 declared | es 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 call | no 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 instance | lleva 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:
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})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.
// 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ímite | En la frontera |
|---|---|
| aspectos por vista · máquinas por vista · profundidad de anidamiento dentro de un aspecto | la Declaration se rechaza en playserv push, nunca se trunca en silencio |
| tamaño de instancia almacenada | la 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ón | la 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áneo | una lectura fuera de la ventana se rechaza, no se responde con el valor más cercano |
| tasa de cambio sobre una instancia | un 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.
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
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.
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.
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é
| Actor | En esta página |
|---|---|
schema-author | declara stats, abilities, projectiles, tablas de drop, objetos del mundo |
room-owner | ajusta los números de los presets, tira las tablas de drop, crea objetos del mundo |
player | lanza abilities, hace disparos, recoge botín, interactúa con objetos |
De un vistazo
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})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.
// 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
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.
| Preset | Qué le da el contrato | Dónde se ajusta |
|---|---|---|
stats | un 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ódigo | la Declaration del campo; los números quedan editables en vivo en el panel |
abilities | un aspecto con un conjunto de abilities, una máquina de fases de aplicación, y costo y cooldown | la Declaration de la ability |
projectiles | un tipo con persistencia runtime, un aspecto de balística, y un Event de impacto | la Declaration del projectile — un cambio de atributo cambia el modelo de vuelo |
drops | un aspecto de tabla de drop con pesos, y un Hook posterior a la muerte | las entradas y los pesos de la tabla |
world objects | una máquina de estados de un objeto interactivo, y un aspecto de la condición de interacción | la Declaration del preset, o por instancia en la creación |
| inventory | un 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 declarado | la 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.
| Siempre | Qué es |
|---|---|
where it sits | sobre 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 |
tuning | es 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 one | es 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 own | es 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 —
fnoadm. Una clave de jugador o de cliente que intente uno recibeforbidden, 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 unitem: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.
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.
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é
| Actor | En esta página |
|---|---|
room-owner | registra 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-validator | acepta o rechaza solicitudes de entrada con un código y una razón |
room-visitor | navega, entra con datos, se reconecta dentro de la ventana de tolerancia, sale |
spectator | entra sin disputar; recibe difusiones y tráfico en vivo |
match-organizer | reserva asientos que cuentan para la capacidad; una reserva vence en el plazo del template (90s en battle) |
De un vistazo
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 longAttribute-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
[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.
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
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))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.
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.
| Declara | Qué es |
|---|---|
capacity | en 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 |
visibility | enumerable · por nombre o código · oculta |
creation mode | uno de tres, y el modo on first join está obligado a declarar un Hook de inicialización |
two independent timeouts | el 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 window | dentro de ella un retorno restaura la misma membresía y el mismo asiento, en vez de crear un participante nuevo |
authority mode | our simulation o external authority, y no hay valor por defecto |
trust in a reported outcome | para autoridad externa: aceptarlo · comprobarlo con un Hook · no aceptarlo. Otra vez sin valor por defecto |
behaviour when the host drops | esperar la ventana de tolerancia · cerrar la Room · admitir un reemplazo |
map instance and world strata | opcionalmente, qué instancia ocupa y qué estratos dentro de ella |
room-scoped entities | cuá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.
| De | Estados |
|---|---|
| una Room | created → open → closed → torn down, donde torn down es terminal y closed significa sin entradas nuevas y no desaparecida |
| una membresía | active ⇄ inactive → departed, con departed terminal para esa membresía |
Qué vale para toda Room.
| Siempre | Qué es |
|---|---|
losing a connection and leaving | son 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 replay | una reconexión retoma desde el estado de la sesión; el módulo no promete los Events del hueco |
a spectator | no 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 roles | un dueño de Room es un Actor que ostenta un derecho (Access), no un rango en la lista de miembros |
presence | tiene 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 interface | es la de una Room específica: te diriges a esta Room, no solo a su tipo |
three axes, not two | la 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 |
Los dos modos de autoridad.
| Modo | Quién ejecuta el Tick |
|---|---|
| our simulation | nuestra implementación de Room y sus módulos |
| external authority | un 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 line | se 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 identical | las 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 move | ningú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 outcome | es 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.
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()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.
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();| Siempre | Qué es |
|---|---|
the handle | es 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) |
registration | toma 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 | |
|---|---|
entry | es 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 reservations | Matchmaking 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 leave | un 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 journal | quien 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ó.
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.
| Si te refieres a | Lee |
|---|---|
| qué cliente recibe qué estado, y cuánto de él | Visibility, con Data y Prediction |
| qué máquina es dueña de la Entity, y qué pasa cuando muere | What 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 en | Que nombra | |
|---|---|---|
| quién ve qué | el aspect — Data, Visibility | el predicado de visibilidad, el tope de objetos y su orden, qué áreas vecinas se ven, y el modo de entrega |
| qué máquina lo ejecuta | el tipo de Room — Rooms | el 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.
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.
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ón | Paquetes por Actor | |
|---|---|---|
| Envía | la Room entera, a todos | a cada jugador solo la porción que seleccionan sus reglas |
| Sirve para | una Room chica; este es el valor por defecto | una multitud, donde el tamaño del paquete debe seguir siendo predecible |
| Lee la Declaration | una vez, para la Room | por 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é
| Actor | En esta página |
|---|---|
schema-author | declara el predicado de visibilidad, el tope de objetos y su orden, qué áreas vecinas son visibles, y el modo de entrega |
any | se 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
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 scopeAttribute-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
[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.
| Declara | Qué es |
|---|---|
predicate | la 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 orden | una 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 areas | si 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 mode | shared 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.
| Siempre | Qué es |
|---|---|
visibility | no 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 recipient | puede 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 |
truncation | es 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 |
degradation | está 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 shape | es 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
fnadm— 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.
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.
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.
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.
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
Joinya lo cubren.
Quién hace qué
| Actor | En esta página |
|---|---|
player | crea y cancela su propio ticket, y entra como parte de una party |
match-organizer | declara las colas del matchmaker y su relajación; lee los resultados de colocación |
backend-service | sella criterios de confianza antes del encolado; ejecuta decisiones de matchmakers externos |
De un vistazo
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Parte | Qué es | Quién le cree |
|---|---|---|
self-description | las propiedades declaradas del participante — puntuación, modo, idioma, mapa elegido | nadie sin una comprobación: es lo que afirma quien llama |
requirement | un predicado que el resto debe satisfacer | la 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.
| Declara | Qué es |
|---|---|
properties | por nombre y tipo. Una propiedad no declarada aquí se rechaza en un ticket como fallo de validación en vez de ignorarse |
roster size | un 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 ladder | un 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 language | el 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 |
mutuality | si es aceptable un listado en el que A acepta a B mientras B no acepta a A. No hay valor por defecto |
ticket lifetime | tras el cual el ticket transiciona a expired con un Event |
outcome | RoomPlacement — 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.
| Siempre | Qué es |
|---|---|
one live ticket per participant per queue | un segundo es un conflicto, no una segunda solicitud — lee el existente |
the reason for a pairing | es 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 |
expiry | es 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 outcome | llega 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 ticket | declarado 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 |
matched | es 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
expiredcon 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.
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.
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
RandomPositionbasada en reglas, sin atajos. - Las arenas deberían regenerarse por partida — una
Seeddeclarada 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é
| Actor | En esta página |
|---|---|
schema-author | declara mapas, primitivas de obstáculo, destructibles, capas y sus reglas |
room-owner | liga un mapa a una Room; pide posiciones de spawn; lanza raycasts |
operator | coloca o quita obstáculos y capas desde el panel |
De un vistazo
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 assetAttribute-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
[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 assetScatter 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 repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});El modelo
Dos capas, declaradas por personas distintas.
| Capa | Qué lleva, y quién la declara |
|---|---|
static | terreno con altura, primitivas de obstáculo, límites y lugares — contenido escrito |
dynamic | obstá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.
| Declara | Qué es |
|---|---|
key y version | un 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 |
terrain | un 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 |
obstacles | un 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áculo | impasable · 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 |
bounds | el volumen fuera del cual una posición es inadmisible |
world strata | estratos espaciales declarados dentro de un mapa — suelo, subsuelo, aire. Son Declarations de geometría y de direccionamiento |
locations | lugares 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 generator | opcionalmente, 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 stratum | una Declaration dentro del mapa — suelo, subsuelo, aire |
map instance | una 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.
| Siempre | Qué es |
|---|---|
one geometric canon | toda 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 model | es 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 moment | una 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 values | son 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 contact | no 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.
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.
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é
| Actor | En esta página |
|---|---|
room-owner | declara 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:
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
])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
[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.
| Declara | Qué es |
|---|---|
shape | una 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 lives | sobre 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 checked | stepwise — 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 in | dentro de qué volúmenes se lo cuenta |
its relation to the art model | no 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:
| Respuesta | Qué significa |
|---|---|
stop | el movimiento cesa en la última posición admisible |
slide | el movimiento continúa a lo largo del obstáculo con la componente que sea admisible |
bounce | la dirección se refleja y la velocidad se multiplica por un coeficiente declarado |
damp | el movimiento continúa con la velocidad multiplicada por una fracción declarada |
pass | el obstáculo no afecta al movimiento, pero el contacto sigue siendo observable |
cease to exist | la 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.
| Siempre | Qué es |
|---|---|
every pair | tiene una respuesta: un par que falta es un defecto de la Declaration, rechazado en el deploy en vez de encontrado en combate |
the response table | es 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 platforms | la 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 |
simultaneity | está 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 fact | un 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 contact | antes 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 module | no 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 event | que 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.
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.
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é
| Actor | En esta página |
|---|---|
schema-author | declara modelos de movimiento, restricciones y ligaduras |
room-owner | aplica impulso, teletransporte y modificadores desde el host |
player | enví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
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)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
[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:
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateVerbos del lado del servidor:
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)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.
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.
| Declara | Qué es |
|---|---|
movement model | uno 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 |
parameters | valores 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 |
limits | velocidad, 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 input | stop, 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 tolerance | qué 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 cap | con qué frecuencia corre el paso, y cuántos pasos pueden darse de una vez cuando el servidor va atrasado |
impulse kinds | cada uno con su magnitud y su manera de decaer |
Qué vale para todo paso.
| Siempre | Qué es |
|---|---|
the module owns | la 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 |
authority | es del servidor: bajo el modo our simulation un cliente envía una intención, nunca un resultado |
collisions | no 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 |
input | es una intención: «adelante», «derecha», «gira la torreta hacia allá» — aceptada tal como llega, porque no afirma nada sobre el mundo |
a claimed pose | es 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 sequencing | es obligatoria: el mismo número de secuencia nunca se aplica dos veces, y uno menor se descarta |
a limit | recorta, 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 constraints | el 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 bits | no 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 step | es 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.
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.
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:
ResolveAtrebobina 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é
| Actor | En esta página |
|---|---|
schema-author | declara los campos predichos frente a los solo-autoritativos; fija la ventana de predicción |
room-owner | resuelve impactos sobre un estado histórico; rebobina el mundo |
player | predice 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.
| Mecanismo | Qué hace | Corre en | Cuándo se equivoca |
|---|---|---|---|
| Predecir tu propio movimiento | aplica el modelo declarado a tu propia entrada sin esperar al servidor | el cliente | una corrección, reproducida y suavizada |
| Mostrar a los demás jugadores | dibuja a las demás Entities entre los estados que llegan | el cliente | un tirón visible |
| Compensación de lag | rebobina los objetivos al momento que vio el tirador | el servidor | alguien 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.
| Preset | Predice lo tuyo | Compensa | Suaviza a los demás |
|---|---|---|---|
| shooter | sí | en una ventana de alrededor de segundo y medio | sí |
| arcade | sí | no | sí |
| observer | no | no | sí |
| sin predicción | no | no | no — 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.
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 predictedAttribute-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
[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»:
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
var arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareEl 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.
| Declara | Qué es |
|---|---|
predictable aspects | cuáles puede hacer avanzar el cliente por delante de la autoridad |
divergence threshold | por 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 entities | interpolación entre estados llegados, o extrapolación |
interpolation delay | cuánto se retrasa la representación de los demás, declarado en vez de ajustado a ojo |
extrapolation window | más allá de ella una Entity queda marcada como rancia y la extrapolación cesa |
compensation window | hasta dónde atrás puede llegar un rebobinado, y es managed |
what is rewound | posiciones 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 | |
|---|---|
rewound | las posiciones y orientaciones de los objetivos, y la geometría de los obstáculos dinámicos donde el tipo los declara históricos |
not rewound | el estado de vida — los muertos no reviven para que les disparen — y la propiedad, el puntaje y el inventario |
the rule behind the split | la decisión se toma en el pasado; el efecto se aplica en el presente |
| Siempre | Qué es |
|---|---|
authoritative state names the input it saw | lleva el número de la última entrada aplicada, que es lo que hace la reconciliación exacta y no aproximada |
divergence | es observable: el cliente sabe que su predicción fue corregida, en vez de derivar calladamente |
a view time | es 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 point | la misma restricción que en todo el resto del contrato |
one history ring, two consumers | la 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.
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
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í:
- Acepta el estado autoritativo.
- Reproduce las entradas almacenadas que vinieron después de la que reconoce.
- 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.
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
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.
| Mecanismo | Corre en | Cuándo se equivoca |
|---|---|---|
| predecir el propio | el cliente | una corrección, reproducida y suavizada |
| mostrar a los demás jugadores | el cliente | un tirón visible |
| compensación de lag | el servidor | alguien 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.
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
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.
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.
Cuándo usarlo
- Tus lobbies necesitan llenarse en horas de poca gente —
FillRoomcompleta 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
ConnectAsBotcomo 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é
| Actor | En esta página |
|---|---|
bot-brain | se conecta como jugador; recibe percepción; envía comandos |
room-owner | declara perfiles, llena Rooms hasta la cuota, traspasa bot/humano |
De un vistazo
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 inputsEl 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.
| Declara | Qué es |
|---|---|
thinking tick | con 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 brains | dó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 preset | los derechos del bot, como un preset de Actor corriente |
visibility of the bot marker | si 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 unavailable | uno de tres, sin valor por defecto: do nothing · leave the room · fall back to built-in default behaviour |
roster filling | declarado 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.
| Siempre | Qué es |
|---|---|
an actor, not a player | lleva una credencial de Actor pero no tiene proveedor de login, ni vínculos, ni sesiones |
economic ownership | no 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 |
perception | es 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 perception | es 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 thoughts | rige 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 capacity | cuenta 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.
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ón | exactamente 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. |
| comandos | exactamente 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.
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.
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 —
Linkagrega 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
bannedque 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
authno 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é
| Actor | En esta página |
|---|---|
player | inicia sesión, vincula o desvincula identidades, refresca, cierra sesión |
moderator | revoca sesiones; banea, suspende o restaura jugadores |
backend-service | controla el sign-in por región; siembra las primeras filas de un jugador nuevo; lee y revoca sesiones |
De un vistazo
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesCada punto se personaliza donde se declara; las formas que puede tomar un manejador están reunidas en Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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:
| Tipo | Cuando el manejador mismo falla | Al rechazar |
|---|---|---|
| una compuerta | el paso se rechaza — un control de región inalcanzable no es un control de región aprobado | un 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 observador | el paso queda hecho, así que un pack de bienvenida que no aterrizó cuesta un cofre, no el sign-in | no 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
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.
| Siempre | Qué es |
|---|---|
provider + subject | es ú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 player | una segunda cuenta del mismo proveedor es un conflicto |
an external subject | nunca 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 status | son 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 credential | son 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 sessions | cada una revocada de forma independiente |
a credential's claims | son contexto declarado — región, configuración regional — y solo contexto. Una afirmación nunca lleva autoridad |
a device fingerprint | no 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.
| De | Estados |
|---|---|
| la clase de identidad | anonymous → registered, y la transición es de una sola vía |
| el estado de acceso | active ⇄ suspended, y active → banned → active para un desbaneo |
| el jugador | alive → merged, donde merged es terminal: un jugador fusionado no vuelve a iniciar sesión |
Qué declara el consumidor.
| Declara | Qué es |
|---|---|
sign-in policy | si 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 role | 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 roles, que es una Declaration legítima y no una omisión |
session policy | qué 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 policy | có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.
| Siempre | Qué es |
|---|---|
it is not self-promotion | otorgar 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 idempotent | otorgar 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 instant | y 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 role | se 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 —
mergedes 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.
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.
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_idy 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é
| Actor | En esta página |
|---|---|
schema-author | marca Entities como propiedad de un jugador y declara cuáles de ellas forman el perfil |
player | lee su propio perfil; las escrituras van a las Entities mismas |
room-visitor | lee 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-sideAttribute-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
[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.
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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 leavesEl modelo
| Concepto | Qué es |
|---|---|
player_id | toda la idea que la plataforma tiene de un jugador, más el perfil de sistema que hay detrás — Auth |
| conjunto del perfil | las Entities con dueño jugador que el Project declara como su perfil; declararlo es opcional |
| selección por dueño | la 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ública | esa 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.
| Siempre | Qué es |
|---|---|
there is no profile record | no 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 lives | parchea la fila progress, y toda lectura de perfil que la incluya ve el valor nuevo en su siguiente pasada |
there is no public write | una vista no tiene adónde escribir, y el estado compartido y escribible pasa por código de servidor |
declaring the set is optional | y 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 predicate | no 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 hook | un 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 —
fnoadm. 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.
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.
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é
| Actor | Puede | No puede |
|---|---|---|
player | proponer 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 entrada | leer la lista de relaciones de ningún otro, bajo relación de participante alguna |
moderator | decidir sobre invitaciones y solicitudes de entrada allí donde ostente el átomo de administración de membresía | decidir sobre una intención para la que no tiene permiso — eso responde forbidden |
El modelo
Qué lleva una Declaration de relación.
| Declara | Qué es |
|---|---|
kind | simé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 rule | tras un rechazo: prohibida · permitida tras un periodo declarado · permitida de inmediato. Declarada, porque «volver a pedir» es una decisión de producto |
presence visibility | un 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 broken | tras 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.
| Estado | Significado |
|---|---|
proposed | el iniciador propuso y el otro lado no ha respondido |
mutual | ambos lados están de acuerdo |
declined | el otro lado rechazó. La relación se conserva, porque la regla de reinvitación necesita saberlo |
broken | un lado dejó una relación mutua |
blocked | un lado bloqueó al otro |
Qué vale para toda relación.
| Siempre | Qué es |
|---|---|
one entity per pair | no dos registros espejados. «A le propuso a B» y «a B le propuso A» son un solo hecho leído desde dos lados |
an initiator | está declarado: quién propuso, cosa que necesitan tanto la representación como la regla de reinvitación |
blocked dominates | de ahí no hay transición a proposed ni a mutual |
a block | es 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 block | no lo revela: la operación responde not found, así que un Actor bloqueado no puede descubrir el bloqueo sondeando |
the block state | es propiedad de aquí y se consume en otra parte: Messaging y otros lo leen; ninguno lo muta, y ninguno guarda copia |
presence | se deriva de las sesiones: nadie la escribe, y el predicado de visibilidad se aplica por solicitante y no una vez por Actor |
a deferred intent | no 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 group | conserva 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 overwrites | las 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
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.
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é
| Actor | En esta página |
|---|---|
player | envía y recibe mensajes; lee el historial; silencia o bloquea |
moderator | filtra, censura y prohíbe términos |
backend-service | envía o programa notificaciones con plantilla |
De un vistazo
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)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.
// 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")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.
[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:
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declara | Qué es |
|---|---|
the group | su listado — un participante es un Actor, exactamente como en Groups |
binding to a lifetime | opcionalmente 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.
| Parte | De quién es |
|---|---|
envelope | de la plataforma: el autor, la conversación, el momento según el reloj declarado |
payload | del 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.
| De | Estados |
|---|---|
| una conversación | created → active → closed |
| un mensaje | sent → published | rejected by the filter, y luego editado o borrado, de forma observable |
| una notificación | created → queued → delivered | expired |
Qué vale para todo mensaje.
| Siempre | Qué es |
|---|---|
order within a conversation | es estable y declarado. El orden entre conversaciones no está prometido |
editing and deleting | son 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 |
history | son 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 window | el 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 state | es 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 |
sending | es idempotente por clave: dos llamadas son dos líneas de diálogo, así que la clave es lo que hace seguro un reintento |
blocking | es 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 payload | la 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 route | no es parte del contrato: push, dentro de la app, u otra cosa es una decisión de enrutamiento, no una promesa |
delivery | es 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.
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.
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
Grantde commerce con origenreward(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é
| Actor | En esta página |
|---|---|
player | navega los storefronts, compra, gestiona la billetera, canjea códigos |
seller | configura el catálogo, los precios y los horarios de storefront |
backend-service | valida recibos; recotiza u otorga mediante Hooks de compra |
De un vistazo
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declara | Qué es |
|---|---|
key | es contenido escrito, direccionado por una clave para que un renombrado en código sea un renombrado |
kind | consumable — se gasta; o durable — se posee una vez |
prices | un 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 provider | una ranura por proveedor, declarada, porque una tienda conoce el ítem por su propio id |
what it points at | opcionalmente 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 purchasable | un í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 third | un 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 price | se expresa mediante un storefront y no sobre el ítem |
Qué declara un storefront.
| Declara | Qué es |
|---|---|
offers | el conjunto, cada una apuntando a un ítem |
schedule | en tiempo de reloj de pared, siempre UTC: cuándo abre y cierra la ventana |
audience | un 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.
| Desde | Hasta |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid o granted | refunded |
| Siempre | Qué es |
|---|---|
the price | se 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 payment | tiene un plazo declarado, declarado por proveedor, porque difieren |
granting | está 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 refund | es 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 entitlement | lleva 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 entitlement | se acumula: cambia por un incremento con clave de idempotencia, nunca sobrescribiendo lo que se leyó |
ownership | es un predicado de dueño: un entitlement le pertenece a un jugador por el mismo mecanismo que cualquier fila con dueño |
the catalog | se 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 secrets | viven en el plano del operador, nunca en la Declaration, y nunca en un repositorio |
a provider's capabilities | está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.
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ú.
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é
| Actor | En esta página |
|---|---|
player | lee sus propias tenencias y gasta de ellas |
backend-service | otorga, incrementa y revoca en nombre de un jugador, nombrando al jugador por el que actúa |
De un vistazo
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)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.
| Declara | Qué es |
|---|---|
| un tipo con dueño | la 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álogo | la 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 incremento | una 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 frontera | uno 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.
| Siempre | Qué es |
|---|---|
the cap has no default | las 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 immutable | nada 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 players | es una promesa distinta: necesita depósito en garantía y antifraude, y queda fuera de esta versión |
a row | representa 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.
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.
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é
| Actor | En esta página |
|---|---|
player | lee top-N/alrededor-de-mí/su propio rango, se suscribe a los cambios de rango |
backend-service | envía resultados; los corrige o los rechaza en el Hook previo al envío; otorga recompensas cuando se cierra un ciclo |
operator | declara tableros; cierra un ciclo antes de tiempo, corrige registros (auditado), vigila las tasas de envío |
De un vistazo
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 itAttribute-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
[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:
Agg | Un segundo envío | Idempotente |
|---|---|---|
Set | reemplaza el registro por los valores enviados | sí |
Best | lo reemplaza solo cuando los valores nuevos quedan más arriba según la clave de orden | sí |
Increment | suma los valores enviados al registro — bajas, vueltas, contribución al gremio | no — lleva una clave de idempotencia |
Decrement | los resta | no — 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:
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 llamada | Qué es |
|---|---|
PlayServ · playserv | el 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ía | la 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 |
playerId | el 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:
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("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.
| Eje | Valores | Cómo lo fijas |
|---|---|---|
| Dueño | jugador · Group | Owner = Owner.Player — un tablero de gremio es el mismo tablero con Owner.Group |
| Clave de orden | uno o más campos declarados, cada uno ascendente o descendente | [Rank(1, Sort.Descending)] int Score |
| Agregación | set · best · increment · decrement | Agg = Aggregation.Best |
| Reinicio | una programación en UTC; un ciclo vence, nunca borra | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Quién puede enviar | solo el servidor (el valor por defecto) · los jugadores | Submit = Submit.ServerOnly |
| Campos de visualización | declarados y tipados; nunca parte del orden | [Display] string Map |
| Lista de dueños | elegida en tiempo de lectura, no declarada | ForOwners("weekly-score", ids) — amigos, un gremio, un lobby |
| Reglas de torneo | ventana de inscripción · máximo de inscritos · intentos por ciclo · entrada obligatoria | Rules = 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.
| Siempre | Qué es |
|---|---|
direction and operator | son 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 generation | un segundo no es una segunda fila |
an entry | no 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 order | son visualización, y por eso se declaran aparte |
a generation | vence, no borra: open → expired → evicted from retention, y las generaciones vencidas siguen siendo legibles durante el periodo de retención declarado |
the schedule transition | es 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 submitter | es el servidor: quién puede enviar se declara, y el valor por defecto no es el jugador |
a board | es 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 |
Qué es un ciclo, y qué hace cerrar uno.
| Qué es | |
|---|---|
a reset | cierra un ciclo en vez de borrarlo |
a closed cycle | deja 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 event | lleva 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.
| Hook | Qué puede hacer |
|---|---|
pre-submit | un 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-closed | un observer: se dispara a posteriori, no puede vetar, y un fallo ahí deja el ciclo cerrado |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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ón | Declarada como | En la frontera |
|---|---|---|
| ventana de inscripción | entryWindow: TimeSpan — cuánto sigue abierta la entrada tras abrirse el ciclo | una entrada posterior a su cierre se rechaza; el ciclo igual corre hasta su reinicio |
| máximo de inscritos | maxEntrants: int — registros en un ciclo | el 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 ciclo | attemptsPerCycle: int — envíos por dueño | el siguiente envío responde «intentos agotados» — un conflicto, no un error de permiso, y el contador se reinicia con el ciclo |
| entrada obligatoria | joinRequired: true — los inscritos son una membresía, no todo el que juega | un 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ímite | En la frontera | Número |
|---|---|---|
| filas por lectura | la página se recorta, «hay más» sigue verdadero, after: continúa | tope de página fijado por Project |
| ventana alrededor de un dueño | recortada simétricamente | tope de ventana fijado por Project |
| registros en un ciclo | el envío se rechaza como conflicto; sin desalojo | maxEntrants por tablero; sin cota cuando no se fija |
| intentos por dueño por ciclo | conflicto «intentos agotados», resuelto por el reinicio | attemptsPerCycle por tablero; sin cota cuando no se fija |
| tasa de envío por dueño | rechazo por límite de tasa que lleva el momento en que se permite un reintento | tasa fijada por Project |
| tableros por Project | una Declaration nueva se rechaza en el deploy | límite fijado por Project |
| retención de ciclos cerrados | el ciclo sale del almacenamiento con un Event; las lecturas responden después not-found | ventana 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.
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.
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é
| Actor | En esta página |
|---|---|
player | sube trozos, lee archivos por Stream, envía UGC |
moderator | revisa la cola, aprueba o rechaza envíos |
backend-service | deriva variantes de activos; engancha la subida y la moderación; fija cuotas |
De un vistazo
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clLas compuertas a su alrededor son Hooks, el mismo contrato que en todas partes:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declara | Qué es |
|---|---|
origin | contenido 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 types | como una lista declarada, nunca olfateados de los bytes |
size limits | comprobados cuando se abre la sesión, por el tamaño declarado, y no en la última parte |
part size and order | la subida se realiza mediante una sesión: un tamaño de parte declarado, el orden de las partes, un punto de reanudación |
derivatives | opcionalmente, 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 prefix | sobre 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.
| Siempre | Qué es |
|---|---|
completion | es idempotente por sesión: una finalización repetida devuelve el mismo archivo y no un segundo |
a checksum | es obligatoria, y un desajuste es un rechazo, nunca una aceptación silenciosa de bytes corruptos |
a published file | es inmutable: una edición es una versión nueva, y una referencia a una versión sigue apuntando a lo que apuntaba |
authored content | se 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 session | muere de forma observable: pasado su plazo se la termina con un Event y sus partes se liberan |
ownership | sigue 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 access | puede 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, noforbidden— 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 —
expiredcon 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.
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.
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é
| Actor | Puede | No puede |
|---|---|---|
any actor | declarar tipos en el schema; emitir en su propio nombre, de a uno o en lote; leer los tipos declarados | completar el contexto; leer, consultar o agregar lo emitido |
backend-service | lo mismo, y emitir en nombre de un jugador por delegación | leer telemetría — no hay permiso de lectura, porque no hay operación de lectura |
De un vistazo
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))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.
[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.
| Declara | Qué es |
|---|---|
name | el del propio tipo |
fields | tipados por el sistema de tipos de la plataforma; una máscara de campos les aplica como en todas partes |
schema version | obligatoria, 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 |
sampling | qué 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 tolerance | si este tipo tolera pérdida. La telemetría es el único lugar del contrato donde la pérdida declarada es lícita |
deletion behaviour | có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.
| Siempre | Qué es |
|---|---|
no addressing target | sin 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 |
context | es 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 share | viaja con el evento: sin ella el número absoluto no puede reconstruirse a partir de lo que llegó |
loss | es 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 |
emission | no 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 events | son 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.
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 SDK | El plano del operador | |
|---|---|---|
| Se alcanza desde | el código del juego | el Panel de Control, la CLI, MCP |
| Sostiene | Rooms · Entities · jugadores · comercio · leaderboards | Projects y Environments · deploy y rollback · facturación · administración de organización y usuarios · enrutamiento del clúster |
| Trabaja en | Declarations, Hooks, Events, Operations | las 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 SDK | El operador ve |
|---|---|
| Schema / Data | Entities, migraciones, el navegador de registros, vistas guardadas, importación/exportación |
| Entity | máquinas de estados, inspección por instancia |
| Entity Presets | tablas de drop, definiciones de ability, Stat y projectile, presets de objetos del mundo — ajustables en vivo |
| Access | la grilla de roles: roles × operaciones, filtros de fila, máscaras de columnas |
| Extensibility | cadenas de escenario con sobrescrituras, orden resuelto, trazas de invocación |
| Rooms | la flota: Rooms, salud del Tick, colocación, estado de drenado |
| Matchmaking | colas, tickets en curso, curvas de relajación |
| Commerce | catálogo, programación de storefronts, recibos, reembolsos |
| Leaderboards | ciclos, corrección de registros (auditada), tasas de envío |
| Auth | proveedores, sesiones, baneos, el escenario de auth |
| Files | activos, colas de revisión de UGC, cuotas |
| Analytics | tableros, reenviadores, retraso de ingesta |
| Map | mapas y conjuntos de obstáculos, instancias vivas |
| Visibility / Collision / Locomotion / Prediction | ajuste por Room: reglas, pares de respuesta, ventanas, costo de paquete por Actor |
| Groups / Messaging | navegador de Groups, plantillas, filtros de moderación, horarios |
| Bots | perfiles, cuotas de llenado, endpoints de cerebros |
| Inventory / Profile | tenencias 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.
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
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:
| Eje | Rango |
|---|---|
| Forma | dirigido por mensajes o dirigido por peticiones |
| Canales | de un solo canal o de varios canales |
| Estado | con recuperación del estado de conexión, o sin ella |
| Protocolo | TCP 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:
| Nivel | Significado |
|---|---|
at least once | se reentrega hasta que se confirma; el receptor tolera duplicados |
at most once | se envía una vez, nunca se reintenta; la pérdida es aceptable |
exactly once | deduplicado 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:
- 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.
- 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.
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.
La simulation n'est pas votre code
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.
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
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 :
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)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
[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 bloc | Vient de |
|---|---|
Vector3, Stat | le paquet core de votre binding |
Body, et les formes de corps | Collision |
Motion, et les cinq modèles de déplacement | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | les entity presets qui les utilisent |
EntryRequest, Verdict, StatEvent | des charges utiles de Hook, remises par le module que vous accrochez |
Seat | Matchmaking |
Scope, les portées de synchronisation | Visibility |
Tick, les cadences de Tick | Rooms |
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 :
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)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
[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 |
|---|---|
rock | un prop dans l'obstacle set de la carte (Map) |
ammo.shell, railgun | des 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, shell | les 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 :
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 ligne | Ce qui l'accomplit réellement |
|---|---|
Stats.Depleted se déclenche | le Stat atteignant son plancher — 0 pour Hp, puisque la Declaration n'a fixé que Max |
| la transition de mort | AtMin = "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 |
RollAt | généré sur la Declaration [DropTable] — c'est pourquoi elle est partial, et pourquoi l'onglet Go se lit drops.RollAtCrateLoot |
| le ramassage | rouler 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) :
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)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.
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 :
| Appel | Ce qu'il renvoie |
|---|---|
Find | un 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 |
Join | se 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 |
Cast | tirer 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) |
Abilities | gé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 bord | Ce que reçoit l'appelant |
|---|---|
| une entrée au-delà de la capacité, ou dans une Room fermée | conflict — à retenter quand un siège se libère |
| une création de Room au-delà de la limite par Project ou par Actor | refusée, et rien de déjà créé n'est détruit |
| créer des Rooms ou se connecter trop vite | un refus de limite de débit portant le temps d'attente |
| une charge utile d'Event au-delà du plafond de la Room | refusée avant l'envoi, jamais tronquée |
| une lecture au-delà du plafond de lignes d'un rôle | la 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
- 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.
- 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).
- 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 êtes | Lisez, 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é Unreal | Rooms (Héberger une Room) → Bots → Locomotion · World Objects → Map → Ce qui survit à la perte d'un hôte |
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.
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)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
[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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
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))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.
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
- Leaderboards — les axes, les cycles, les tournois, et le Hook de pré-soumission qui plafonne les scores suspects.
- Extensibility — chaque point d'accroche, dans l'ordre, avec le contrat de veto.
- Entity presets — le seuil de Stat qui a déclenché le kill à l'étape 2.
- Des caisses de soin dans Tanks — l'autre exemple Tanks : deux Declarations et un Hook.
- Getting Started — l'arène Tanks que cette leçon étend.
- Notions essentielles — le vocabulaire que chaque page de module suppose acquis.
Des caisses de soin dans Tanks
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.
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)])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
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.
[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)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
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 healthavec 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émetchanged; 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é
| Avant | Après | |
|---|---|---|
| un tank endommagé | reste endommagé jusqu'à sa mort | peut récupérer en parcourant l'arène |
| code de Room | aucun | toujours 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'
entityplutô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.
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.
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)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
[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.
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)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.
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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
- Leaderboards — les axes de tournoi (fenêtre d'inscription, inscrits maximum, tentatives).
- Matchmaking → Rooms — les groupes, le placement et l'amorçage.
- Extensibility → Commerce → Messaging — la chaîne de Hooks qui verse.
- Notions essentielles — le vocabulaire que chaque page de module suppose acquis.
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.
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 :
| Surface | Sens |
|---|---|
| Declarations | ce 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 |
| Hooks | vos règles, appelées par la plateforme à des étapes nommées ; déployées comme fonctions cloud |
| Events | ce que la plateforme vous dit qu'il s'est passé — abonnez-vous, ne faites pas de polling |
| Operations | ce 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 :
| Tag | Surface |
|---|---|
fn | Fonction cloud (C# · TypeScript · Python · Go). Fait autorité côté serveur ; l'endroit principal où vivent vos règles |
cl | Client de jeu (Unreal C++ / Unity C#). API symétrique ; les rôles débloquent moins |
mc | La 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 |
adm | Panneau 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.
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
| Question | Ré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 cloud | client de jeu | hôte de Room | admin | |
|---|---|---|---|---|
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.
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.
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
| Actor | Sur cette page |
|---|---|
operator | déclare les rôles et les politiques, fixe les limites de lignes/colonnes, accorde les rôles, émet les clés |
match-organizer | le personnel de tournoi du flux ci-dessous : tient une clé composée, filtre les entrées, ne peut pas rembourser |
every actor | vérifie CanI avant d'agir ; ne voit que les interfaces qu'il a débloquées |
En un coup d'œil
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()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.
| Terme | Ce que c'est |
|---|---|
atom | une 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) |
role | un 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 preset | livré 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 predicate | quelles lignes — un prédicat booléen sur les valeurs de session |
field mask | quels 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.
| Credential | Ce qu'elle débloque |
|---|---|
player key | un 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 key | un serveur dédié ou un master-client la tient, et ses rôles débloquent les lignes mc |
pushed code | s'exécute sous le rôle backend-service du Project — c'est ce que vérifie le Authoritative = true d'un Leaderboard |
a registered hook | n'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.
| Toujours | Ce que c'est |
|---|---|
a credential | ne 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 |
delegation | change 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 verb | ré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 row | répond not found : un refus ne doit pas devenir un oracle d'existence |
an owner | se voit toujours lui-même, quoi que dise un prédicat par ailleurs |
visibility | n'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 module | n'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 surface | suit 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 |
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, pasforbidden— 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
forbiddenlà 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.
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
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.
| Niveau | Ce qui vit ici |
|---|---|
| Les principes communs | Identiques 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 langage | Seulement 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 moteur | Seulement 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.
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
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.
// 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, idempotentAttribute-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.
// 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, idempotentArrê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.
// 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 downAttribute-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.
// 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 voulez | Ce que c'est |
|---|---|
| arrêter de me livrer | local. Réussit toujours, y compris connexion coupée. Libérer un abonnement, c'est ceci. |
| arrêter le travail | une 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
- Getting Started — la première Room, de bout en bout.
- Comment le SDK est construit — pourquoi il y a une seule surface et comment les pièces s'emboîtent.
- Sous le capot — la couche en dessous de celle-ci, si vous êtes curieux.
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.
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
Problemtypé 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
| Actor | Sur cette page |
|---|---|
any actor | lit l'identité, le contexte, les rôles via Whoami |
backend-service | agit en tant que joueur ; regroupe en lots des opérations idempotentes |
operator | lit les traces des appels échoués ou réessayés |
En un coup d'œil
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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.
| Champ | Ce que c'est |
|---|---|
code | le 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 |
category | la classe dont relève le refus, et c'est elle qui dit si un réessai a le moindre sens |
trace identifier | l'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 |
explanation | du 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 errors | la 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.
| Origine | Ce qui s'est passé |
|---|---|
platform | elle a répondu par un refus, portant un code du catalogue de la plateforme |
local | le SDK a refusé avant d'envoyer, depuis son propre vocabulaire publié |
unknown | l'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.
| Toujours | Ce que c'est |
|---|---|
a refused operation applied nothing | l'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 path | ne 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 timeout | n'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
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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.
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.
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.eton.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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les Events avec [Event], pousse le schéma |
any actor | émet via send., s'abonne via on. |
En un coup d'œil
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))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éclare | Ce que c'est |
|---|---|
name | un nom de fil explicite, déclaré plutôt que dérivé du symbole |
payload | le schéma de ce que porte une émission |
target | où 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 |
clock | sim_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 |
retention | transient — 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 |
term | sur un type retained : combien de temps il est gardé, et ce qui arrive à l'expiration. « Pour toujours » n'est pas l'une des valeurs |
delivery | au 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 |
context | le 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.
| Champ | Ce que c'est |
|---|---|
type | l'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 |
payload | conforme au schéma du type |
source | l'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 |
stamp | sur l'horloge déclarée du type |
dedup key | pré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 key | sur 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.
| Tient | Ce que c'est |
|---|---|
event | le type déclaré auquel il est lié |
surface | le 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é |
handler | typé sur la charge utile |
position | l'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.
| Toujours | Ce que c'est |
|---|---|
audience | jamais é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 |
ordering | promis à 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 streams | quand 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 detection | là 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.
| Transient | Retained | |
|---|---|---|
| Atteint | qui est abonné à cet instant | cela, plus un abonné arrivant ensuite |
| Ensuite | disparu | gardé pour un terme déclaré |
| Relisible | non | oui, sur le terme |
| Au-delà du terme | — | une sélection refuse, au lieu de répondre vide |
[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)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
Problemtypé — 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.
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.
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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les RPC, leurs modes et qui a le droit de les appeler |
any actor | invoque un appel avec réponse ou à sens unique, là où la Declaration l'autorise |
group member | répond à un appel en fan-out ; une réponse revient par membre |
En un coup d'œil
[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)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éclare | Ce que c'est |
|---|---|
name | issu du vocabulaire des verbes |
input | les arguments que l'appelant doit choisir |
output | exactement 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 mode | with 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 mode | immediate — 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 |
streaming | si l'entrée et la sortie arrivent par parties et sont traitées à mesure, plutôt que d'un bloc |
idempotency | un RPC à sens unique porte lui aussi une clé d'idempotence : pas de réponse ne veut pas dire pas de re-livraison |
overridability | déclarée sur la méthode elle-même. Pas de Declaration veut dire non redéfinissable — jamais redéfinissable par défaut |
context | là 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.
| Porte | Ce que c'est |
|---|---|
arguments | seulement ce que l'appelant doit choisir |
implicit context | le 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 |
references | un 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 |
outcome | une valeur du type de sortie déclaré, ou un Problem typé |
Ce que tient le descripteur d'un appel différé.
| Tient | Ce que c'est |
|---|---|
state | accepted → running → completed ou failed, les deux derniers terminaux |
lifetime | déclarée ; au-delà, l'issue est indisponible et la demander est un refus, pas une réponse vide |
cancel | idempotent, 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.
| Toujours | Ce que c'est |
|---|---|
one handler | exactement 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 |
meaning | une 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 machine | une 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 stream | n'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 write | aucune é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.
[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 ranexport 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 ranAttribute-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 ranErreurs
- 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.
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.
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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les aspects, leur politique de synchronisation et le prédicat de visibilité |
any actor | s'abonne à une cible ; reprend depuis une position ; demande l'état complet |
backend-service | Hooks d'avant et d'après changement |
operator | lit 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
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 consequenceLe modèle
Ce que porte un Delta.
| Champ | Ce que c'est |
|---|---|
changed fields | ceux-là seulement, jamais l'objet entier |
pair | la paire instance × aspect à laquelle il appartient |
number | un 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éclare | Valeurs, et ce que ce n'est pas |
|---|---|
priority | ordonne 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 rate | une borne supérieure sur l'envoi. Pas une promesse de réception à cette cadence — la réception dépend du canal |
delta only | ne pas envoyer ce qui n'a pas changé |
delivery mode | shared 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 rule | le prédicat qui décide qui reçoit tout court — Visibility projette cette moitié en entier |
Ce que tient un abonnement.
| Tient | Ce que c'est |
|---|---|
target | une 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 |
position | l'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 |
state | active → gap detected → resynchronised | closed, et closed est terminal |
Ce qui vaut pour tout Stream.
| Toujours | Ce que c'est |
|---|---|
merging | les 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 detection | perdre silencieusement un Delta est interdit ; le numéro de séquence dans la paire est ce que le consommateur compte |
ordering | tient à l'intérieur d'une paire instance × aspect ; entre paires il n'est promis sous aucune forme |
traversal | ne 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 |
history | est 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 budget | se 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.
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | Ce qu'il a le droit de faire |
|---|---|
| avant un changement | le 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 changement | ajouter 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
closedde 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.
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.
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
| Actor | Sur cette page |
|---|---|
player | cré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-owner | les règles de sièges d'une Room chevauchent cette Primitive (configurées dans Rooms) |
backend-service | déclare les types de Group et leurs règles ; Hooks à l'entrée et à la sortie |
En un coup d'œil
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 groupAttribute-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 groupLe 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.
[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: ...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() };
[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éclare | Ce que c'est |
|---|---|
name | celui du type |
membership mode | explicit — 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 |
rule | pour 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 |
capacity | et le comportement lorsqu'elle est atteinte |
entry rule | un 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 |
lifetime | optionnelle : une fois expirée, le Group se ferme avec un Event |
Ce qui vaut pour tout Group.
| Toujours | Ce que c'est |
|---|---|
member | un Actor, jamais une Entity : un ensemble d'Entities est une sélection sur Data. Un Group est un auditeur de masse |
states | created → 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 call | est 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 outcome | ne 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 |
recipients | ne 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 roles | n'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 exit | sont 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 |
recomputation | porte 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 leave | sont 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 interface | est celle d'un Group concret, pas seulement du type : vous adressez cette escouade |
the primitive | reste 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.
AddouRemovesur 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 = 4ci-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é.
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.
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/Afterordonné, 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
| Actor | Sur cette page |
|---|---|
backend-service | redéfinit des maillons, enveloppe des étapes d'intergiciel, écrit des gestionnaires de déclencheurs |
operator | inspecte les chaînes, fixe l'ordre, lit les secrets, simule la résolution à blanc |
En un coup d'œil
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Le modèle
Ce que la plateforme vous donne pour vous y attacher.
| Terme | Ce que c'est |
|---|---|
registered function | une étape redéfinissable de la plateforme — « créer un profil », « résoudre un prix » |
scenario | la chaîne ordonnée qu'exécute un flux de plateforme : authentification, entrée, achat |
overridability | si un maillon peut être remplacé, seulement enveloppé, ou est figé |
middleware | un gestionnaire pré/post ordonné autour d'un maillon |
trigger | ce qui démarre votre code : un Event, une planification, un webhook |
secret | une valeur que votre gestionnaire a le droit de lire |
invocation | une exécution, avec sa trace |
Ce qu'un Hook déclare.
| Déclare | Ce que c'est |
|---|---|
position | l'étape nommée à laquelle il s'attache |
kind | gatekeeper — 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 |
moment | before — 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 |
effect | ce 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 condition | un 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 |
| Toujours | Ce que c'est |
|---|---|
all three | se déploient avec playserv push |
the two attribute shapes | sont 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 form | porte 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 link | laisse 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.
| Toujours | Ce que c'est |
|---|---|
four directions | une fonction cloud · le backend externe du consommateur · le serveur de jeu · un autre, déclaré |
the router | est piloté par messages/signaux ; request-response en est un adaptateur plutôt que sa nature |
matching | se 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 twice | est 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 name | répond « not found », plutôt que d'être abandonné en silence |
the direction | ne 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 RPCs | s'enregistrent dans le même routeur : en déclarer un, c'est l'enregistrer, et il n'y a pas de seconde façon |
ordering | exé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 constraint | un 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 effect | est 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 violation | est 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 time | ce serait une seconde réponse à une question tranchée, posée au seul moment où l'on n'y peut plus rien |
| Toujours | Ce que c'est |
|---|---|
handlers | sont 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 sees | les 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 |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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-openoufail-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é.
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.
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.
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 codegenré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
| Actor | Sur cette page |
|---|---|
schema-author | déclare Entities/parts/enums en code, compare et pousse |
operator | relit la vue d'ensemble du panneau, propose et applique les migrations |
ci | le 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
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 = 0Attribute-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
[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.
| Porte | Ce que c'est |
|---|---|
key | le nom stable par lequel elle est adressée. Un renommage dans le code est un renommage, pas une suppression suivie d'une création |
kind | une Entity, une part ou un enum, déclarés en code |
ownership mode | seed — 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 |
preset | optionnellement : 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.
| Toujours | Ce que c'est |
|---|---|
matching | se fait par clé, jamais par symbole : un push répété après renommage du symbole laisse un enregistrement plutôt que deux |
idempotency | en découle — un push répété n'est pas un second enregistrement |
the report | dit exactement ce qui va changer avant l'application, et ce qu'il a écrasé ensuite |
origin | est distinguable : un enregistrement créé par un push depuis le code se distingue d'un enregistrement créé ailleurs |
the revision | voyage avec lui, et un push atterrit en entier ou pas du tout |
Ce que la codegen promet.
| Toujours | Ce que c'est |
|---|---|
regeneration | a 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 |
naming | suit 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 directions | ne 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.
| Toujours | Ce que c'est |
|---|---|
when one is required | un changement de Declaration persistante qui réécrit des valeurs existantes, et un changement persistant cassant ne peut pas être publié sans elle |
what it declares | une version, un aperçu, une application ordonnée, un retour arrière en cas d'échec, et une issue d'achèvement observable |
coexistence | pendant 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
forbiddenet 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 estprecondition_failedet 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.
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.
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_timepassé, 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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les Entities, les aspects, les machines à états, les presets |
every actor | interroge, s'abonne, appelle les RPC d'Entity, lit l'état |
En un coup d'œil
[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()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();
};
[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.blockeds'associe àopende lui-même, si bien que la transitionclose_requesteddéclarée suropens'applique à l'intérieur sans être répétée. Tant que la machine est dansopen.blocked, elle est dansopen— une vérification d'état pouropenest vraie, etOnEntered("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
openingabandonne sonAfterSeconds, 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 Declaration | Ce que cela veut dire |
|---|---|
caller in entity.room | n'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 |
Actor | l'identité de l'appelant, le même objet que renvoie whoami |
caller.Inventory | le 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 :
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"))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.
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
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éclare | Ce que c'est |
|---|---|
aspect | un 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 machine | des états imbriquables d'un niveau, des transitions et des gardes ; plusieurs par Entity |
trigger | ce qui déclenche une transition — les quatre sources sont ci-dessous |
entity RPC | un 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 event | un signal que l'Entity émet, livré à quiconque s'abonne à cette instance |
hook | avant 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 track | si la vue garde ou non la fenêtre instantanée |
ref | un 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.
| Source | Comment cela se déclenche |
|---|---|
client event or RPC | n'importe lequel de ceux déclarés — RequestOpen ci-dessus déclenche open_requested |
collision | un contact ou une entrée dans un volume déclencheur — pièges, plaques de pression — à travers l'aspect auquel Collision se lie |
data threshold | déclaré sur un Stat, 0 HP → death, appliqué par l'ordre des Hooks plutôt que par du code dans une Room |
time | AfterSeconds 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.
| Axe | Ce qui est admissible |
|---|---|
filter et sort | des 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 |
include | un ref déclaré, ramené avec la page |
paging | par 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 |
access | les prédicats s'appliquent avant la pagination, si bien qu'une page ne porte jamais de trous là où seraient les lignes cachées |
live | s'abonner à une sélection la garde vivante, avec des membres qui entrent et sortent à mesure que leurs données changent |
// 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()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.
// 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.
| Toujours | Ce que c'est |
|---|---|
a selection | est 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 request | reste 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 guard | est une opération différente avec un atome différent — entity × administer, qu'aucune clé cliente ne tient par défaut |
history | est 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 change | est 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 implementation | est 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 declared | est 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 call | ne 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 instance | porte 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 :
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})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.
// 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.
| Limite | Au bord |
|---|---|
| aspects par vue · machines par vue · profondeur d'imbrication dans un aspect | la Declaration est rejetée à playserv push, jamais tronquée en silence |
| taille d'instance stockée | l'é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élection | la 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 instance | un 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.
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
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.
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.
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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les Stats, les abilities, les projectiles, les tables de drop, les objets de monde |
room-owner | ajuste les nombres des presets, tire les tables de drop, crée des objets de monde |
player | lance des abilities, tire, ramasse du loot, interagit avec les objets |
En un coup d'œil
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})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.
// 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
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.
| Preset | Ce que le contrat lui donne | Où il s'ajuste |
|---|---|---|
stats | un 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 code | la Declaration du champ ; les nombres restent éditables en direct dans le panneau |
abilities | un aspect d'un ensemble d'abilities, une machine de phases d'application, et un coût et un cooldown | la Declaration de l'ability |
projectiles | un type à persistance runtime, un aspect de balistique, et un Event d'impact | la Declaration du projectile — un seul échange d'attribut change le modèle de vol |
drops | un aspect de table de drop avec des poids, et un Hook après la mort | les entrées et les poids de la table |
world objects | une machine à états d'objet interactif, et un aspect de la condition d'interaction | la Declaration du preset, ou par instance à la création |
| inventory | un 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.
| Toujours | Ce que c'est |
|---|---|
where it sits | sur 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 |
tuning | est 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 one | est 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 own | est 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 —
fnouadm. Un joueur ou une clé cliente qui tente l'un d'eux obtientforbidden, 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, unitem:key.bronzemanquant — 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.
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.
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
| Actor | Sur cette page |
|---|---|
room-owner | enregistre 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-validator | accepte ou rejette les demandes d'entrée avec un code et une raison |
room-visitor | parcourt, entre avec des données, se reconnecte dans la fenêtre de tolérance, sort |
spectator | entre sans prendre part à la partie ; reçoit les diffusions et le trafic en direct |
match-organizer | ré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
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 longAttribute-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
[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.
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 :
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))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.
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éclare | Ce que c'est |
|---|---|
capacity | en 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 mode | l'un de trois, et le mode on first join est obligé de déclarer un Hook d'initialisation |
two independent timeouts | le 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 mode | our simulation ou external authority, et il n'y a pas de valeur par défaut |
trust in a reported outcome | pour 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 drops | attendre l'écoulement de la fenêtre de tolérance · fermer la Room · admettre un remplaçant |
map instance and world strata | optionnellement, quelle instance elle occupe et quelles strates à l'intérieur |
room-scoped entities | lesquelles 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 Room | created → open → closed → torn down, où torn down est terminal et closed veut dire plus de nouvelles entrées plutôt que disparue |
| une appartenance | active ⇄ inactive → departed, avec departed terminal pour cette appartenance |
Ce qui vaut pour toute Room.
| Toujours | Ce que c'est |
|---|---|
losing a connection and leaving | sont 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 replay | une reconnexion reprend depuis l'état de session ; le module ne promet pas les Events de l'intervalle |
a spectator | n'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 roles | un propriétaire de Room est un Actor tenant un droit (Access), pas un grade dans la liste des membres |
presence | a 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 interface | est celle d'une Room précise : vous adressez cette Room, pas seulement son type |
three axes, not two | l'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 |
Les deux modes d'autorité.
| Mode | Qui conduit le Tick |
|---|---|
| our simulation | notre implémentation de Room et ses modules |
| external authority | un 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 line | est 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 identical | les 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 move | aucun 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 outcome | est 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.
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()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.
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();| Toujours | Ce que c'est |
|---|---|
the handle | est 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) |
registration | prend 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 | |
|---|---|
entry | est 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 reservations | Matchmaking 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 leave | un 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 journal | un 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é.
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.
| Si vous voulez dire | Lisez |
|---|---|
| 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 meurt | What 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é sur | Qui nomme | |
|---|---|---|
| qui voit quoi | l'aspect — Data, Visibility | le 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écute | le type de Room — Rooms | le 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.
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.
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.
| Diffusion | Paquets par Actor | |
|---|---|---|
| Envoie | toute 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éfaut | une foule, où la taille des paquets doit rester prévisible |
| Lit la Declaration | une fois, pour la Room | par 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
| Actor | Sur cette page |
|---|---|
schema-author | déclare le prédicat de visibilité, le plafond d'objets et son ordre, quelles zones voisines sont visibles, et le mode de livraison |
any | s'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
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 scopeAttribute-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
[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éclare | Ce que c'est |
|---|---|
predicate | la 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 ordre | une 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 areas | si 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 mode | shared 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.
| Toujours | Ce que c'est |
|---|---|
visibility | n'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 recipient | peut 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 |
truncation | est 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 |
degradation | est 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 shape | est 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
fnadm— 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.
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.
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.
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.
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
Joinle couvrent déjà.
Qui fait quoi
| Actor | Sur cette page |
|---|---|
player | crée et annule son propre ticket, et entre au sein d'un groupe |
match-organizer | déclare les files du matchmaker et leur relâchement ; lit les résultats de placement |
backend-service | estampille les critères de confiance avant la mise en file ; exécute les décisions d'un matchmaker externe |
En un coup d'œil
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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 :
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Partie | Ce que c'est | Qui y croit |
|---|---|---|
self-description | les propriétés déclarées du participant — classement, mode, langue, carte choisie | personne sans vérification : c'est la affirmation de l'appelant |
requirement | un prédicat que les autres doivent satisfaire | la 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éclare | Ce que c'est |
|---|---|
properties | par 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 size | un 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 ladder | un 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 language | le 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 |
mutuality | si 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 lifetime | après quoi le ticket passe à expired avec un Event |
outcome | RoomPlacement — 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.
| Toujours | Ce que c'est |
|---|---|
one live ticket per participant per queue | un second est un conflit, pas une seconde candidature — lisez celui qui existe |
the reason for a pairing | est 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 |
expiry | est 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 outcome | arrive 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 ticket | dé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 |
matched | est 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 à
expiredavec 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.
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.
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
RandomPositionfondée sur des règles, sans contournement. - Les arènes doivent se régénérer à chaque partie — un
Seeddé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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les cartes, les primitives d'obstacle, les destructibles, les couches et leurs règles |
room-owner | lie une carte à une Room ; demande des positions d'apparition ; lance des rayons |
operator | place ou retire des obstacles et des couches depuis le panneau |
En un coup d'œil
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 assetAttribute-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
[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 assetScatter 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 repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});Le modèle
Deux couches, déclarées par des gens différents.
| Couche | Ce qu'elle tient, et qui la déclare |
|---|---|
static | terrain avec hauteur, primitives d'obstacle, bornes et emplacements — du contenu écrit |
dynamic | des 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éclare | Ce que c'est |
|---|---|
key et version | une 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 |
terrain | un 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 |
obstacles | un 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 obstacle | infranchissable · 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 |
bounds | le volume hors duquel une position est inadmissible |
world strata | des 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 |
locations | des 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 generator | optionnellement, 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 stratum | une déclaration à l'intérieur de la carte — sol, souterrain, air |
map instance | une 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.
| Toujours | Ce que c'est |
|---|---|
one geometric canon | toute 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 model | est 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 moment | une 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 values | sont 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 contact | ne 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.
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.
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
| Actor | Sur cette page |
|---|---|
room-owner | dé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 :
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
])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
[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éclare | Ce que c'est |
|---|---|
shape | une 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 lives | sur un aspect, avec la transformation : c'est l'unité de politique, et un corps partage le sort de sa transformation |
how its path is checked | stepwise — 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 in | quels volumes le comptent comme étant à l'intérieur |
its relation to the art model | aucune 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éponse | Ce que cela veut dire |
|---|---|
stop | le mouvement cesse à la dernière position admissible |
slide | le mouvement continue le long de l'obstacle selon la composante admissible |
bounce | la direction est réfléchie et la vitesse multipliée par un coefficient déclaré |
damp | le mouvement continue avec la vitesse multipliée par une fraction déclarée |
pass | l'obstacle n'affecte pas le mouvement, mais le contact reste observable |
cease to exist | l'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.
| Toujours | Ce que c'est |
|---|---|
every pair | a 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 table | est 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 platforms | la 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 |
simultaneity | est 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 fact | un 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 contact | avant 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 module | ne 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 event | et 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.
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.
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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les modèles de déplacement, les contraintes et les liaisons |
room-owner | applique impulsion, téléportation et modificateurs depuis l'hôte |
player | soumet 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
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)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
[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 :
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateVerbes côté serveur :
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)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.
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éclare | Ce que c'est |
|---|---|
movement model | l'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 |
parameters | des 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 |
limits | vitesse 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 input | stop, 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 kinds | chacune avec son amplitude et sa manière de décroître |
Ce qui vaut pour tout pas.
| Toujours | Ce que c'est |
|---|---|
the module owns | la 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 |
authority | est celle du serveur : sous le mode our simulation, un client envoie une intention, jamais un résultat |
collisions | ne 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 |
input | est 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 pose | est 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 sequencing | est requis : le même numéro de séquence n'est jamais appliqué deux fois, et un numéro plus bas est écarté |
a limit | borne, 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 constraints | le 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 bits | aucun 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 step | est 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.
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.
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 :
ResolveAtrembobine 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
| Actor | Sur cette page |
|---|---|
schema-author | déclare les champs prédits et ceux réservés à l'autorité ; fixe la fenêtre de prédiction |
room-owner | résout les impacts sur un état historique ; rembobine le monde |
player | pré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écanisme | Ce qu'il fait | S'exécute sur | Quand il se trompe |
|---|---|---|---|
| Prédire votre propre mouvement | applique le modèle déclaré à votre propre entrée sans attendre le serveur | le client | une correction, rejouée et lissée |
| Afficher les autres joueurs | dessine les autres Entities entre les états qui arrivent | le client | une saccade visible |
| Compensation de lag | rembobine les cibles jusqu'au moment que le tireur voyait | le serveur | quelqu'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.
| Preset | Prédit le vôtre | Compense | Lisse les autres |
|---|---|---|---|
| shooter | oui | dans une fenêtre d'environ une seconde et demie | oui |
| arcade | oui | non | oui |
| observer | non | non | oui |
| pas de prédiction | non | non | non — 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.
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 predictedAttribute-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
[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 » :
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 :
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
var arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareLe 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éclare | Ce que c'est |
|---|---|
predictable aspects | lesquels le client a le droit d'avancer devant l'autorité |
divergence threshold | en 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 entities | interpolation entre les états arrivés, ou extrapolation |
interpolation delay | de combien l'affichage des autres est en retard, déclaré plutôt que réglé au feeling |
extrapolation window | au-delà, une Entity est marquée périmée et l'extrapolation cesse |
compensation window | jusqu'où en arrière un rembobinage a le droit d'aller, et c'est managed |
what is rewound | les 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 | |
|---|---|
rewound | les positions et orientations des cibles, et la géométrie des obstacles dynamiques là où le type les déclare historiques |
not rewound | l'é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 split | la décision est prise dans le passé ; l'effet est appliqué au présent |
| Toujours | Ce que c'est |
|---|---|
authoritative state names the input it saw | il 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 |
divergence | est observable : le client sait que sa prédiction a été corrigée, au lieu de dériver en silence |
a view time | est 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 point | la même contrainte que partout ailleurs dans le contrat |
one history ring, two consumers | la 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.
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
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à :
- Acceptez l'état faisant autorité.
- Rejouez les entrées mises en tampon venues après celle qu'il accuse.
- 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.
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
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écanisme | S'exécute sur | Quand il se trompe |
|---|---|---|
| prédire votre propre mouvement | le client | une correction, rejouée et lissée |
| afficher les autres joueurs | le client | une saccade visible |
| compensation de lag | le serveur | quelqu'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.
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
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.
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.
Quand l'utiliser
- Vos lobbies ont besoin d'être remplis aux heures creuses —
FillRoomcomplè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
ConnectAsBotcomme 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
| Actor | Sur cette page |
|---|---|
bot-brain | se connecte comme joueur ; reçoit la perception ; envoie des commandes |
room-owner | déclare les profils, remplit les Rooms jusqu'au quota, passe la main bot/humain |
En un coup d'œil
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 inputsLe 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éclare | Ce 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 brains | où 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 preset | les droits du bot, comme un preset d'Actor ordinaire |
visibility of the bot marker | si 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 unavailable | l'un de trois, avec aucune valeur par défaut : do nothing · leave the room · fall back to built-in default behaviour |
roster filling | dé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.
| Toujours | Ce que c'est |
|---|---|
an actor, not a player | il tient une credential d'Actor mais n'a pas de fournisseur de connexion, pas de liens et pas de sessions |
economic ownership | est 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 |
perception | est 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 perception | est 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 thoughts | la 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 capacity | compte 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.
É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 | |
|---|---|
| perception | exactement 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. |
| commandes | exactement 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é.
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.
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 —
Linkajoute 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
bannedque 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
authne 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
| Actor | Sur cette page |
|---|---|
player | se connecte, lie ou délie des identités, rafraîchit, se déconnecte |
moderator | révoque des sessions ; bannit, suspend ou restaure des joueurs |
backend-service | filtre 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
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesChaque point se personnalise là où il est déclaré ; les formes qu'un gestionnaire peut prendre sont rassemblées dans Extensibility :
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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 :
| Genre | Quand le gestionnaire lui-même échoue | Au refus |
|---|---|---|
| une barrière | l'étape est refusée — un contrôle régional inatteignable n'est pas un contrôle régional réussi | un 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 observateur | l'étape reste faite, si bien qu'un pack de démarrage qui n'a pas abouti coûte un coffre, pas la connexion | il 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
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.
| Toujours | Ce que c'est |
|---|---|
provider + subject | est 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 player | un second compte du même fournisseur est un conflit |
an external subject | n'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 status | sont 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 credential | sont 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 sessions | chacune révoquée indépendamment |
a credential's claims | sont du contexte déclaré — région, langue — et du contexte seulement. Une claim ne porte jamais d'autorité |
a device fingerprint | n'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ès | active ⇄ suspended, et active → banned → active pour un débannissement |
| le joueur | alive → merged, où merged est terminal : un joueur fusionné ne se reconnecte pas |
Ce que le consommateur déclare.
| Déclare | Ce que c'est |
|---|---|
sign-in policy | si 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 role | 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 |
session policy | ce 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 policy | comment 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.
| Toujours | Ce que c'est |
|---|---|
it is not self-promotion | attribuer 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 idempotent | attribuer 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 instant | et 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 role | est 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 —
mergedest 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.
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.
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_idet 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
| Actor | Sur cette page |
|---|---|
schema-author | marque les Entities comme possédées par un joueur et déclare lesquelles forment le profil |
player | lit son propre profil ; les écritures vont vers les Entities elles-mêmes |
room-visitor | lit 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-sideAttribute-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
[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.
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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 leavesLe modèle
| Concept | Ce que c'est |
|---|---|
player_id | toute l'idée que la plateforme se fait d'un joueur, plus le profil système derrière lui — Auth |
| ensemble de profil | les Entities possédées par un joueur que le Project déclare comme son profil ; le déclarer est optionnel |
| sélection par propriétaire | la 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 publique | cette 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.
| Toujours | Ce que c'est |
|---|---|
there is no profile record | il 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 lives | retouchez la ligne progress, et chaque lecture de profil qui l'inclut voit la nouvelle valeur à sa passe suivante |
there is no public write | une vue n'a rien où écrire, et l'état partagé accessible en écriture passe par du code serveur |
declaring the set is optional | et 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 predicate | pas 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 hook | un 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 —
fnouadm. 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.
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.
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
| Actor | Peut | Ne peut pas |
|---|---|---|
player | proposer 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ésion | lire la liste de relations de quelqu'un d'autre, sous quelque relation de participant que ce soit |
moderator | décider des invitations et des demandes d'adhésion là où il tient l'atome d'administration d'appartenance | dé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éclare | Ce que c'est |
|---|---|
kind | symé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 rule | aprè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 visibility | un 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 broken | aprè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.
| État | Sens |
|---|---|
proposed | l'initiateur a proposé et l'autre côté n'a pas répondu |
mutual | les deux côtés sont d'accord |
declined | l'autre côté a refusé. La relation est conservée, parce que la règle de réinvitation a besoin de le savoir |
broken | un côté a quitté une relation mutuelle |
blocked | un côté a bloqué l'autre |
Ce qui vaut pour toute relation.
| Toujours | Ce que c'est |
|---|---|
one entity per pair | pas 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 initiator | est déclaré : qui a proposé, ce dont l'affichage et la règle de réinvitation ont tous deux besoin |
blocked dominates | de là il n'y a aucune transition vers proposed ni mutual |
a block | est 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 block | ne 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 state | est 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 |
presence | est 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 intent | n'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 group | conserve 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 overwrites | les 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
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.
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
| Actor | Sur cette page |
|---|---|
player | envoie et reçoit des messages ; lit l'historique ; met en sourdine ou bloque |
moderator | filtre, caviarde et bannit des termes |
backend-service | envoie ou planifie des notifications gabaritées |
En un coup d'œil
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)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.
// 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")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.
[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 :
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 :
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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éclare | Ce que c'est |
|---|---|
the group | sa liste — un participant est un Actor, exactement comme dans Groups |
binding to a lifetime | optionnellement 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 |
payload | au 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 conversation | created → active → closed |
| un message | sent → published | rejected by the filter, puis édité ou supprimé, de façon observable |
| une notification | created → queued → delivered | expired |
Ce qui vaut pour tout message.
| Toujours | Ce que c'est |
|---|---|
order within a conversation | est stable et déclaré. L'ordre entre conversations n'est pas promis |
editing and deleting | sont 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 |
history | ce 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 window | la 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 state | est 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 |
sending | est idempotent par clé : deux appels sont deux répliques, la clé est donc ce qui rend un réessai sûr |
blocking | est 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 payload | la 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 route | ne fait pas partie du contrat : push, dans l'application, ou autre chose est une décision de routage, pas une promesse |
delivery | est 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.
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.
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
Grantde commerce avec l'originereward(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
| Actor | Sur cette page |
|---|---|
player | parcourt les boutiques, achète, gère son portefeuille, échange des codes |
seller | configure le catalogue, les prix et les plannings de boutique |
backend-service | valide les reçus ; retarifie ou attribue via les Hooks d'achat |
En un coup d'œil
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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éclare | Ce que c'est |
|---|---|
key | c'est du contenu créé, adressé par une clé, si bien qu'un renommage dans le code est un renommage |
kind | consumable — il se dépense ; ou durable — possédé une fois |
prices | un 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 provider | un emplacement par fournisseur, déclaré, parce qu'une boutique externe connaît l'article par son propre id |
what it points at | optionnellement 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 purchasable | un 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 third | un 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 price | s'exprime par une boutique plutôt que sur l'article |
Ce qu'une boutique déclare.
| Déclare | Ce que c'est |
|---|---|
offers | l'ensemble, chacune pointant vers un article |
schedule | en temps d'horloge, toujours en UTC : quand la fenêtre ouvre et ferme |
audience | un 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.
| De | Vers |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid ou granted | refunded |
| Toujours | Ce que c'est |
|---|---|
the price | est 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 payment | a une échéance déclarée, déclarée par fournisseur, parce qu'ils diffèrent |
granting | est 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 refund | est 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 entitlement | porte 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 entitlement | s'accumule : il change par un incrément avec une clé d'idempotence, jamais en écrasant ce qui a été lu |
ownership | est un prédicat de propriétaire : un entitlement appartient à un joueur par le même mécanisme que toute ligne possédée |
the catalog | est 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 secrets | vivent dans le plan opérateur, jamais dans la Declaration, et jamais dans un dépôt |
a provider's capabilities | sont 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.
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.
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
| Actor | Sur cette page |
|---|---|
player | lit ses propres détentions et dépense depuis elles |
backend-service | accorde, incrémente et révoque pour le compte d'un joueur, en nommant le joueur pour lequel il agit |
En un coup d'œil
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)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éclare | Ce 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 catalogue | la 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ément | une 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 bord | l'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.
| Toujours | Ce que c'est |
|---|---|
the cap has no default | les 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 immutable | rien 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 players | est une autre promesse : elle exige un séquestre et de l'anti-fraude, et elle est hors de cette version |
a row | affiche 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.
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.
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
| Actor | Sur cette page |
|---|---|
player | lit le top-N / autour de moi / son propre rang, s'abonne aux changements de rang |
backend-service | soumet des résultats ; les corrige ou les rejette dans le Hook de pré-soumission ; accorde les récompenses à la fermeture d'un cycle |
operator | déclare les tableaux ; ferme un cycle par anticipation, corrige des enregistrements (audité), surveille les cadences de soumission |
En un coup d'œil
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 itAttribute-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
[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 :
Agg | Une seconde soumission | Idempotent |
|---|---|---|
Set | remplace l'enregistrement par les valeurs soumises | oui |
Best | ne le remplace que lorsque les nouvelles valeurs se classent plus haut selon la clé d'ordre | oui |
Increment | ajoute les valeurs soumises à l'enregistrement — kills, tours, contribution de guilde | non — portez une clé d'idempotence |
Decrement | les soustrait | non — 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 :
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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'appel | Ce que c'est |
|---|---|
PlayServ · playserv | le 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 soumet | la 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 |
playerId | l'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 :
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("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.
| Axe | Valeurs | Comment vous le réglez |
|---|---|---|
| Propriétaire | joueur · Group | Owner = Owner.Player — un tableau de guilde est le même tableau avec Owner.Group |
| Clé d'ordre | un ou plusieurs champs déclarés, chacun croissant ou décroissant | [Rank(1, Sort.Descending)] int Score |
| Agrégation | set · best · increment · decrement | Agg = Aggregation.Best |
| Réinitialisation | un planning en UTC ; un cycle expire, ne supprime jamais | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Qui a le droit de soumettre | serveur seulement (la valeur par défaut) · joueurs | Submit = Submit.ServerOnly |
| Champs d'affichage | déclarés et typés ; jamais partie de l'ordre | [Display] string Map |
| Liste de propriétaires | choisie à la lecture, non déclarée | ForOwners("weekly-score", ids) — amis, guilde, lobby |
| Règles de tournoi | fenêtre d'inscription · inscrits maximum · tentatives par cycle · adhésion requise | Rules = 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.
| Toujours | Ce que c'est |
|---|---|
direction and operator | sont 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 generation | une seconde n'est pas une seconde ligne |
an entry | n'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 order | ce sont des champs d'affichage, et c'est pourquoi ils sont déclarés séparément |
a generation | expire, 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 transition | est 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 submitter | est le serveur : qui a le droit de soumettre est déclaré, et la valeur par défaut n'est pas le joueur |
a board | est 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 |
Ce qu'est un cycle, et ce que sa fermeture fait.
| Ce que c'est | |
|---|---|
a reset | ferme un cycle plutôt que de le supprimer |
a closed cycle | cesse 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 event | porte 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.
| Hook | Ce qu'il a le droit de faire |
|---|---|
pre-submit | un 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-closed | un observer : déclenché après coup, il ne peut pas opposer de veto, et un échec là laisse le cycle fermé |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 :
| Contrainte | Déclarée comme | Au bord |
|---|---|---|
| fenêtre d'inscription | entryWindow: TimeSpan — combien de temps l'adhésion reste ouverte après l'ouverture du cycle | une adhésion après sa fermeture est refusée ; le cycle continue jusqu'à sa réinitialisation |
| inscrits maximum | maxEntrants: int — enregistrements dans un cycle | le 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 cycle | attemptsPerCycle: int — soumissions par propriétaire | la 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 requise | joinRequired: true — les inscrits sont une appartenance, pas tous ceux qui jouent | une 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.
| Limite | Au bord | Nombre |
|---|---|---|
| lignes par lecture | la page est rognée, « il y en a d'autres » reste vrai, after: continue | plafond de page fixé par Project |
| fenêtre autour d'un propriétaire | rognée symétriquement | plafond de fenêtre fixé par Project |
| enregistrements dans un cycle | la soumission est refusée comme conflit ; pas d'éviction | maxEntrants par tableau ; sans borne si non réglé |
| tentatives par propriétaire et par cycle | conflit « tentatives épuisées », levé par la réinitialisation | attemptsPerCycle par tableau ; sans borne si non réglé |
| cadence de soumission par propriétaire | refus de limite de débit portant le moment où un réessai est permis | cadence fixée par Project |
| tableaux par Project | une nouvelle Declaration est refusée au déploiement | limite fixée par Project |
| rétention des cycles fermés | le cycle quitte le stockage avec un Event ; les lectures répondent alors not-found | fenê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.
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.
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
| Actor | Sur cette page |
|---|---|
player | téléverse des morceaux, lit des fichiers en flux, soumet du UGC |
moderator | examine la file, approuve ou rejette les soumissions |
backend-service | dérive les variantes d'asset ; accroche l'upload et la modération ; fixe les quotas |
En un coup d'œil
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clLes barrières autour sont des Hooks, même contrat que partout :
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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éclare | Ce que c'est |
|---|---|
origin | contenu é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 types | comme une liste déclarée, jamais devinée à partir des octets |
size limits | vé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 order | l'upload est effectué par une session : une taille de morceau déclarée, l'ordre des morceaux, un point de reprise |
derivatives | optionnellement, 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 prefix | sur 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.
| Toujours | Ce que c'est |
|---|---|
completion | est idempotente par session : une finalisation répétée renvoie le même fichier plutôt qu'un second |
a checksum | est obligatoire, et un désaccord est un refus, jamais une acceptation silencieuse d'octets corrompus |
a published file | est immuable : une retouche est une nouvelle version, et une référence à une version continue de pointer là où elle pointait |
authored content | est 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 session | meurt de façon observable : au-delà de son échéance elle est terminée avec un Event et ses morceaux sont libérés |
ownership | suit 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 access | peut 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, pasforbidden— 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 —
expiredavec 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.
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.
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
| Actor | Peut | Ne peut pas |
|---|---|---|
any actor | déclarer des types dans le schéma ; émettre pour son propre compte, un à un ou en lot ; lire les types déclarés | remplir le contexte ; lire, interroger ou agréger ce qui a été émis |
backend-service | la même chose, et émettre pour le compte d'un joueur par délégation | lire 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
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))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.
[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éclare | Ce que c'est |
|---|---|
name | celui du type |
fields | typés par le système de types de la plateforme ; un masque de champs s'y applique comme partout ailleurs |
schema version | obligatoire, 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 |
sampling | quelle 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 tolerance | si 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 behaviour | comment 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.
| Toujours | Ce que c'est |
|---|---|
no addressing target | pas 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 |
context | est 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 share | voyage avec l'Event : sans elle, le nombre absolu ne peut pas être reconstruit à partir de ce qui est arrivé |
loss | est 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 |
emission | n'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 events | sont 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.
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 SDK | Le plan opérateur | |
|---|---|---|
| Atteint depuis | le code du jeu | le Control Panel, la CLI, MCP |
| Tient | Rooms · Entities · joueurs · commerce · Leaderboards | Projects & Environments · déploiement et retour arrière · facturation · administration de l'organisation et des utilisateurs · routage de grappe |
| Travaille en | Declarations, Hooks, Events, Operations | les é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 SDK | Ce que l'opérateur voit |
|---|---|
| Schema / Data | Entities, migrations, navigateur d'enregistrements, vues sauvegardées, import/export |
| Entity | machines à états, inspection par instance |
| Entity Presets | tables de drop, définitions d'ability, de Stat et de projectile, presets d'objets de monde — réglables en direct |
| Access | la grille des rôles : rôles × opérations, filtres de lignes, masques de colonnes |
| Extensibility | chaînes de scénarios avec redéfinitions, ordre résolu, traces d'invocation |
| Rooms | la flotte : Rooms, santé du Tick, placement, état de vidage |
| Matchmaking | files, tickets en vol, courbes de relâchement |
| Commerce | catalogue, planification des boutiques, reçus, remboursements |
| Leaderboards | cycles, correction d'enregistrements (auditée), cadences de soumission |
| Auth | fournisseurs, sessions, bannissements, le scénario d'authentification |
| Files | assets, files d'examen UGC, quotas |
| Analytics | tableaux de bord, redirecteurs, retard d'ingestion |
| Map | cartes et obstacle sets, instances vivantes |
| Visibility / Collision / Locomotion / Prediction | réglage par Room : règles, paires de réponses, fenêtres, coût de paquet par Actor |
| Groups / Messaging | navigateur de Groups, gabarits, filtres de modération, plannings |
| Bots | profils, quotas de remplissage, points d'accès des cerveaux |
| Inventory / Profile | dé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.
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
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 |
|---|---|
| Forme | piloté par messages ou par requêtes |
| Canaux | monocanal ou multicanal |
| État | avec récupération de l'état de connexion, ou sans |
| Protocole | TCP 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 :
| Niveau | Sens |
|---|---|
at least once | re-livré jusqu'à accusé de réception ; le récepteur tolère les doublons |
at most once | envoyé une fois, jamais retenté ; la perte est acceptable |
exactly once | dé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 :
- 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.
- 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.
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.
A simulação não é o seu código
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.
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
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:
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)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
[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 bloco | Vem de |
|---|---|
Vector3, Stat | o pacote core do seu binding |
Body e as formas de corpo | Collision |
Motion e os cinco modelos de movimento | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | os Entity Presets que os usam |
EntryRequest, Verdict, StatEvent | payloads de Hooks, entregues pelo módulo em que você engancha |
Seat | Matchmaking |
Scope, os escopos de sincronização | Visibility |
Tick, as taxas de Tick | Rooms |
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:
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)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
[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:
| Chave | O que nomeia |
|---|---|
rock | um prop no conjunto de obstáculos do mapa (Map) |
ammo.shell, railgun | itens 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, shell | as 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:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 linha | Quem realmente a executa |
|---|---|
Stats.Depleted dispara | o Stat chegando ao seu piso — 0 para Hp, já que a Declaration definiu só Max |
| a transição de morte | AtMin = "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 |
RollAt | gerado sobre a Declaration [DropTable] — por isso ela é partial, e por isso a aba de Go lê drops.RollAtCrateLoot |
| o apanhar | passar 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):
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)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.
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:
| Chamada | Com o que responde |
|---|---|
Find | um 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 |
Join | resolve 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 |
Cast | atirar é 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) |
Abilities | gerado — 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 fronteira | O que quem chama recebe |
|---|---|
| entrar acima da capacidade, ou numa Room fechada | conflict — vale repetir quando uma vaga abrir |
| criar Room acima do limite por projeto ou por Actor | recusa, e nada já criado é destruído |
| criar Rooms ou fazer sign-in rápido demais | recusa por limite de taxa carregando o tempo de espera |
| payload de Event acima do teto da Room | recusado antes do envio, nunca truncado |
| leitura acima do teto de linhas de um papel | as 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
- 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.
- 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).
- 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 Unreal | Rooms (Hosting a room) → Bots → Locomotion · World Objects → Map → O que sobrevive à perda de um host |
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.
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)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
[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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
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))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.
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
- Leaderboards — os eixos, os ciclos, os torneios e o Hook de pré-envio que corta pontuações suspeitas.
- Extensibility — todos os pontos de Hook, em ordem, com o contrato de veto.
- Entity Presets — o limiar de Stat que disparou o abate no passo 2.
- Caixas de vida em Tanks — o outro exemplo de Tanks: duas Declarations e um Hook.
- Getting Started — a arena Tanks que esta lição estende.
- Conceitos essenciais — o vocabulário que toda página de módulo pressupõe.
Caixas de vida em Tanks
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.
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)])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
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.
[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)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
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 healthcom 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.
Adjustemite o Eventchanged; 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
| Antes | Depois | |
|---|---|---|
| um tanque danificado | fica danificado até morrer | pode se recuperar percorrendo a arena |
| código da Room | nenhum | continua 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
entitye 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.
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.
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)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
[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.
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)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.
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.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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
- Leaderboards — os eixos de torneio (janela de inscrição, teto de participantes, tentativas).
- Matchmaking → Rooms — parties, colocação e semeadura.
- Extensibility → Catalog & Commerce → Messaging — a cadeia de Hooks que paga.
- Conceitos essenciais — o vocabulário que toda página de módulo pressupõe.
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.
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ície | O que significa |
|---|---|
| Declarations | o que existe e como se comporta, escrito em código ou no painel administrativo; o mesmo modelo dos dois jeitos |
| Hooks | as suas regras, chamadas pela plataforma em passos com nome; implantadas como funções de nuvem |
| Events | o que a plataforma conta que aconteceu — assine, não fique consultando |
| Operations | o 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:
| Etiqueta | Superfície |
|---|---|
fn | Função de nuvem (C# · TypeScript · Python · Go). Autoritativa no servidor; a casa principal das suas regras |
cl | Cliente de jogo (Unreal C++ / Unity C#). API simétrica; os papéis destravam menos |
mc | A 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 |
adm | Painel 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.
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
| Pergunta | Respostas |
|---|---|
| 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 nuvem | cliente de jogo | host da Room | admin | |
|---|---|---|---|---|
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.
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.
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 —
CanIavalia 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ê
| Actor | Nesta página |
|---|---|
operator | declara papéis e políticas, define limites de linha/coluna, concede papéis, emite chaves |
match-organizer | a organização do torneio do fluxo abaixo: segura uma chave composta, controla inscrições, não pode reembolsar |
every actor | verifica CanI antes de agir; enxerga apenas as interfaces que destravou |
De relance
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()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.
| Termo | O que é |
|---|---|
atom | um 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) |
role | um 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 preset | vem 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 predicate | quais linhas — um predicado booleano sobre valores da sessão |
field mask | quais 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.
| Credencial | O que destrava |
|---|---|
player key | um 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 key | um servidor dedicado ou um master-client a segura, e os papéis dela destravam as linhas mc |
pushed code | roda com o papel backend-service do projeto — é isso que o Authoritative = true de um Leaderboard confere |
a registered hook | nã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.
| Sempre | O que é |
|---|---|
a credential | nã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 |
delegation | muda 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 verb | responde 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 row | responde not found: uma recusa não pode virar um oráculo de existência |
an owner | sempre enxerga a si mesmo, diga o predicado o que disser |
visibility | não é segurança — uma otimização de canal e uma permissão são mecanismos diferentes, e nenhum substitui o outro |
a disabled module | nã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 surface | segue 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 |
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ãoforbidden— 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
forbiddenonde 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.
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
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ível | O que mora aqui |
|---|---|
| Os princípios comuns | Idê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 linguagem | Só 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 engine | Só 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
groupsob 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.
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
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.
// 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, idempotentAttribute-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.
// 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, idempotentParar 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.
// 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 downAttribute-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.
// 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ê quer | O que é |
|---|---|
| parar de me entregar | local. Sempre tem êxito, inclusive com a conexão caída. Liberar uma assinatura é isto. |
| parar o trabalho | um 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
- Getting Started — a primeira Room, de ponta a ponta.
- Como o SDK é construído — por que há uma superfície e como as peças encaixam.
- Por baixo do capô — a camada abaixo desta, se você estiver curioso.
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.
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
Problemtipado 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ê
| Actor | Nesta página |
|---|---|
any actor | lê identidade, contexto e papéis por Whoami |
backend-service | age como um jogador; agrupa Operations idempotentes em lote |
operator | lê rastros de chamadas que falharam ou foram repetidas |
De relance
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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.
| Campo | O que é |
|---|---|
code | o 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 |
category | a classe em que a recusa se encaixa, que é o que diz se repetir faz algum sentido |
trace identifier | o identificador desta ocorrência específica, presente sempre, inclusive em erros locais, para que falar com o suporte nunca exija reproduzir a falha antes |
explanation | texto para uma pessoa ler, e não é estável: títulos e explicações mudam e são localizados a qualquer momento |
per-field errors | a 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.
| Origem | O que aconteceu |
|---|---|
platform | ela respondeu com uma recusa, carregando um código do catálogo da plataforma |
local | o SDK recusou antes de enviar, a partir do vocabulário publicado dele |
unknown | a chamada foi enviada e resposta alguma voltou. Nem "a plataforma disse não" nem "nós nunca perguntamos" |
O que vale para toda recusa.
| Sempre | O que é |
|---|---|
a refused operation applied nothing | atomicidade é 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 path | nã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 timeout | nã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
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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ê.
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.
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.eon.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ê
| Actor | Nesta página |
|---|---|
schema-author | declara Events com [Event], envia o schema |
any actor | emite por send., assina por on. |
De relance
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))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.
| Declara | O que é |
|---|---|
name | um nome de fio explícito, declarado em vez de derivado do símbolo |
payload | o schema do que uma emissão carrega |
target | para 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 |
clock | sim_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 |
retention | transient — 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 |
term | num tipo retained: por quanto tempo é mantido, e o que acontece no vencimento. "Para sempre" não é um dos valores |
delivery | no 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 |
context | o 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.
| Campo | O que é |
|---|---|
type | o 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 |
payload | conforme o schema do tipo |
source | o 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 |
stamp | pelo relógio declarado do tipo |
dedup key | presente sob todo modo de entrega, porque reentrega é possível em todos eles — um duplicado de transporte, uma segunda leitura de um Event retido |
cause key | num 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.
| Segura | O que é |
|---|---|
event | o tipo declarado ao qual está ligada |
surface | o nó em que foi tomada, dentro do target declarado do tipo — a metade da audiência que cabe ao assinante |
handler | tipado pelo payload |
position | de 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.
| Sempre | O que é |
|---|---|
audience | nunca é 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 |
phases | emitido, depois entregue — e nada mais. Um Event não tem máquina de estados: ele acontece uma vez |
ordering | prometida 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 streams | quando é preciso ordem entre fluxos, o mecanismo é declarado, nunca suposto: traga as mensagens para um fluxo só, ou carregue um carimbo causal no payload |
gap detection | onde 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.
| Transient | Retained | |
|---|---|---|
| Alcança | quem estiver assinando naquele momento | isso, e um assinante que chegar depois |
| Depois | acabou | mantido por um prazo declarado |
| Legível de volta | não | sim, dentro do prazo |
| Passado o prazo | — | uma seleção recusa, em vez de responder vazio |
[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)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
Problemtipado — 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.
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.
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ê
| Actor | Nesta página |
|---|---|
schema-author | declara RPCs, os modos deles e quem pode chamá-los |
any actor | invoca uma chamada com resposta ou unidirecional, onde a Declaration permite |
group member | responde a uma chamada fan-out; uma resposta volta por membro |
De relance
[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)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.
| Declara | O que é |
|---|---|
name | do vocabulário de verbos |
input | os argumentos que quem chama precisa escolher |
output | exatamente 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 mode | with 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 mode | immediate — 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 |
streaming | se a entrada e a saída chegam em partes e são tratadas conforme chegam, em vez de por inteiro |
idempotency | um RPC unidirecional também carrega chave de idempotência: não haver resposta não quer dizer não haver reentrega |
overridability | declarada no próprio método. Nenhuma Declaration significa não sobrescrevível — nunca sobrescrevível por padrão |
context | onde 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.
| Carrega | O que é |
|---|---|
arguments | só o que quem chama precisa escolher |
implicit context | o 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 |
references | um 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 |
outcome | um valor do tipo de saída declarado, ou um Problem tipado |
O que o descritor de uma chamada adiada segura.
| Segura | O que é |
|---|---|
state | accepted → running → completed ou failed, os dois últimos terminais |
lifetime | declarado; passado ele o desfecho fica indisponível e pedi-lo é uma recusa, não uma resposta vazia |
cancel | idempotente, 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.
| Sempre | O que é |
|---|---|
one handler | exatamente 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 |
meaning | um 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 machine | uma 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 stream | nã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 write | escrita 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.
[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 ranexport 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 ranAttribute-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 ranErros
- 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.
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.
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ê
| Actor | Nesta página |
|---|---|
schema-author | declara aspectos, a política de sincronização deles e o predicado de visibilidade |
any actor | assina um target; retoma de uma posição; pede o estado completo |
backend-service | Hooks antes e depois da mudança |
operator | lê o custo de pacote por Actor; vê quando a entrega degrada ou um pacote é cortado |
De relance
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 consequenceO modelo
O que um Delta carrega.
| Campo | O que é |
|---|---|
changed fields | apenas eles, nunca o objeto inteiro |
pair | o par instância × aspecto a que pertence |
number | um 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.
| Declara | Valores, e o que não é |
|---|---|
priority | ordena 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 rate | um limite superior de envio. Não é promessa de recebimento naquela taxa — receber depende do canal |
delta only | não enviar o que não mudou |
delivery mode | shared 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 rule | o predicado que decide quem recebe — Visibility projeta essa metade por inteiro |
O que uma assinatura segura.
| Segura | O que é |
|---|---|
target | uma 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 |
position | de 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 |
state | active → gap detected → resynchronised | closed, e closed é terminal |
O que vale para todo fluxo.
| Sempre | O que é |
|---|---|
merging | Deltas 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 detection | perder um Delta em silêncio é proibido; o número de sequência no par é o que quem consome conta |
ordering | vale dentro de um par instância × aspecto; entre pares não é prometida sob forma alguma |
traversal | percorre 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 budget | degrada 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.
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | O que pode fazer |
|---|---|
| antes de uma mudança | alterá-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ça | acrescentar 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
closedda 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.
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.
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ê
| Actor | Nesta página |
|---|---|
player | cria 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-owner | as regras de vaga de Room viajam neste Primitive (configuradas em Rooms) |
backend-service | declara tipos de Group e as regras deles; engancha Hooks na entrada e na saída |
De relance
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 groupAttribute-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 groupO 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.
[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: ...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() };
[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.
| Declara | O que é |
|---|---|
name | o do próprio tipo |
membership mode | explicit — 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 |
rule | para 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 |
capacity | e o comportamento ao alcançá-la |
entry rule | um predicado que pode rejeitar a entrada, separado de um Hook que também pode rejeitá-la |
lifecycle behaviour | na 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 |
lifetime | opcional: quando expira, o Group fecha com um Event |
O que vale para todo Group.
| Sempre | O que é |
|---|---|
member | é um Actor, nunca uma Entity: um conjunto de Entities é uma seleção sobre Data & Subscriptions. Um Group é um ouvinte em massa |
states | created → active → closed, e closed é terminal. Uma instância de Group tem máquina; o tipo não a declara |
event target | emita nele e os membros dele recebem; é isso que faz da entrega em massa um sinal em vez de um laço |
a group call | sã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 outcome | nunca 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 |
recipients | nunca 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 roles | nã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 exit | sã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 |
recomputation | carrega 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 leave | sã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 primitive | fica 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.
AddouRemovenum 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 = 4acima); 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.
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.
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/Afterordenado 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ê
| Actor | Nesta página |
|---|---|
backend-service | sobrescreve elos, envolve passos com middleware, escreve handlers de trigger |
operator | inspeciona cadeias, define ordem, lê segredos, simula a resolução |
De relance
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
O modelo
A que a plataforma lhe dá para se prender.
| Termo | O que é |
|---|---|
registered function | um passo sobrescrevível da plataforma — "criar perfil", "resolver preço" |
scenario | a cadeia ordenada que um fluxo da plataforma executa: entrada, join, compra |
overridability | se um elo pode ser substituído, apenas envolvido, ou é fixo |
middleware | um handler pré/pós ordenado em torno de um elo |
trigger | o que inicia o seu código: um Event, um agendamento, um webhook |
secret | um valor que o seu handler pode ler |
invocation | uma execução, com o rastro dela |
O que um Hook declara.
| Declara | O que é |
|---|---|
position | o passo nomeado ao qual ele se prende |
kind | gatekeeper — 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 |
moment | before — 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 |
effect | o 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 condition | um 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.
| Forma | Use 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 |
| Sempre | O que é |
|---|---|
all three | são implantados com playserv push |
the two attribute shapes | são o que o painel desenha, porque a Declaration leva o nome do passo ou do elo para o modelo enviado |
the middleware form | leva 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 link | deixa 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.
| Sempre | O que é |
|---|---|
four directions | uma 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 name | responde not found, em vez de ser descartado em silêncio |
the direction | nã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 RPCs | registram-se no mesmo roteador: declarar um é registrá-lo, e não há um segundo jeito |
ordering | roda 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 constraint | um 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 checked | em 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 time | isso seria uma segunda resposta a uma pergunta já resolvida, feita no único momento em que nada pode ser feito a respeito |
| Sempre | O que é |
|---|---|
handlers | sã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 sees | os 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 |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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
kinddeclarado dele —fail-openoufail-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
kinddele 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.
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.
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.
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 codegenregera 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ê
| Actor | Nesta página |
|---|---|
schema-author | declara Entities/parts/enums em código, faz diff e envia |
operator | revisa a visão geral do painel, propõe e aplica migrações |
ci | o pipeline de build, rodando sob uma chave backend-service: envia num merge e regera os tipos de engine em seguida |
De relance
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 = 0Attribute-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
[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.
| Carrega | O que é |
|---|---|
key | o nome estável pelo qual ela é endereçada. Renomear em código é renomear, não apagar e criar |
kind | uma Entity, um part ou um enum, declarado em código |
ownership mode | seed — 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 |
preset | opcionalmente: 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.
| Sempre | O que é |
|---|---|
matching | é por chave, nunca por símbolo: um push repetido depois de renomear o símbolo deixa um registro, não dois |
idempotency | decorre disso — um push repetido não é um segundo registro |
the report | diz 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 revision | viaja junto, e um push chega inteiro ou não chega |
O que a geração de código promete.
| Sempre | O que é |
|---|---|
regeneration | acontece depois de cada push, e os tipos gerados nunca são editados à mão: regerar e depois comparar não produz mudança |
naming | segue 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 directions | nã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.
| Sempre | O que é |
|---|---|
when one is required | uma mudança de Declaration persistente que reescreve valores existentes, e uma mudança persistente quebrante não pode ser publicada sem uma |
what it declares | uma versão, uma prévia, uma aplicação ordenada, um rollback em caso de falha e um desfecho de conclusão observável |
coexistence | enquanto 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
forbiddene o modelo implantado fica intocado: uma recusa nunca é um push parcial. Essa é uma recusa diferente de uma Revision obsoleta, que éprecondition_failede 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.
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.
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_timepassado, 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ê
| Actor | Nesta página |
|---|---|
schema-author | declara Entities, aspectos, máquinas de estado, presets |
every actor | consulta, assina, chama RPC de Entity, lê estado |
De relance
[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()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();
};
[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.blockedse associa aopenpor si só, então a transiçãoclose_requesteddeclarada emopenvale dentro dele sem ser repetida. Enquanto a máquina está emopen.blockedela está emopen— uma checagem de estado paraopené verdadeira, eOnEntered("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
openingdescarta oAfterSecondsdele, 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 Declaration | O que significa |
|---|---|
caller in entity.room | qualquer 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 |
Actor | a identidade de quem chama, o mesmo objeto que whoami devolve |
caller.Inventory | o 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:
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"))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.
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
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.
| Declara | O que é |
|---|---|
aspect | um 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 machine | estados aninháveis um nível, transições e guardas; vários por Entity |
trigger | o que dispara uma transição — as quatro fontes estão abaixo |
entity RPC | um verbo saindo da Entity, declarado dentro da visão com o átomo de direito de que precisa |
entity event | um sinal que a Entity emite, entregue a quem assina aquela instância |
hook | antes 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 track | se a visão mantém a janela instantânea |
ref | um 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.
| Fonte | Como dispara |
|---|---|
client event or RPC | qualquer um declarado — RequestOpen acima dispara open_requested |
collision | um contato ou a entrada num volume de gatilho — armadilhas, placas de pressão — pelo aspecto ao qual Collision se liga |
data threshold | declarado num Stat, 0 HP → death, garantido por ordem de Hooks em vez de por código numa Room |
time | AfterSeconds 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.
| Eixo | O que é admissível |
|---|---|
filter e sort | apenas campos declarados — não existe handle de tabela, e uma seleção é endereçada por Entity, no escopo de uma Room ou do projeto |
include | um ref declarado, trazido junto com a página |
paging | por 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 |
access | predicados se aplicam antes da paginação, então uma página nunca carrega buracos onde estariam linhas escondidas |
live | assinar uma seleção a mantém viva, com membros entrando e saindo conforme os dados deles mudam |
// 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()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.
// 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.
| Sempre | O 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 request | continua 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 call | nã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 instance | carrega 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:
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})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.
// 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.
| Limite | Na fronteira |
|---|---|
| aspectos por visão · máquinas por visão · profundidade de aninhamento dentro de um aspecto | a Declaration é rejeitada no playserv push, nunca truncada em silêncio |
| tamanho da instância armazenada | a 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ção | a 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âneo | uma leitura fora da janela é recusada, não respondida com o valor mais próximo |
| taxa de mudanças numa instância | uma recusa por limite de taxa carregando quanto esperar |
Fluxo do usuário
Uma porta, da Declaration ao som que o jogador ouve.
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
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.
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.
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ê
| Actor | Nesta página |
|---|---|
schema-author | declara Stats, Abilities, Projectiles, tabelas de drop, World Objects |
room-owner | ajusta números de preset, rola tabelas de drop, cria World Objects |
player | usa abilities, atira, apanha loot, interage com objetos |
De relance
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})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.
// 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
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.
| Preset | O que o contrato lhe dá | Onde é ajustado |
|---|---|---|
Stats | um 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ódigo | a Declaration do campo; os números continuam editáveis ao vivo no painel |
Abilities | um aspecto com um conjunto de abilities, uma máquina de fases de aplicação, e custo e cooldown | a Declaration da ability |
Projectiles | um tipo com persistência runtime, um aspecto de balística e um Event de acerto | a Declaration do projétil — trocar um atributo muda o modelo de voo |
Drops | um aspecto de tabela de drop com pesos, e um Hook depois da morte | as entradas da tabela e os pesos delas |
World Objects | uma máquina de estados de objeto interativo, e um aspecto com a condição de interação | a Declaration do preset, ou por instância na criação |
| Inventory | um 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 declarado | a 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.
| Sempre | O que é |
|---|---|
where it sits | numa 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 —
fnouadm. Um jogador ou uma chave de cliente que tente um recebeforbidden, 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, umitem:key.bronzeausente — 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.
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.
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ê
| Actor | Nesta página |
|---|---|
room-owner | registra Rooms pelo processo; numa instância de Room — a interface administrativa por instância: ajusta configuração ao vivo, expulsa, tranca, transmite, descarta |
entry-validator | aceita ou rejeita pedidos de entrada com código e razão |
room-visitor | navega, entra com dados, reconecta dentro da janela de tolerância, sai |
spectator | entra sem disputar; recebe transmissões e tráfego ao vivo |
match-organizer | reserva vagas que contam para a capacidade; uma reserva expira no prazo do template (90s em battle) |
De relance
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 longAttribute-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
[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.
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
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))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.
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.
| Declara | O que é |
|---|---|
capacity | em 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 |
visibility | enumerável · por nome ou código · oculta |
creation mode | um de três, e o modo on first join é obrigado a declarar um Hook de inicialização |
two independent timeouts | o 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 window | dentro dela um retorno restaura a mesma associação e a mesma vaga, em vez de criar um participante novo |
authority mode | our simulation ou external authority, e não há padrão |
trust in a reported outcome | para autoridade externa: aceitá-lo · conferi-lo com um Hook · não aceitá-lo. De novo sem padrão |
behaviour when the host drops | esperar a janela de tolerância · fechar a Room · admitir um substituto |
map instance and world strata | opcionalmente, qual instância ela ocupa e quais estratos dentro dela |
room-scoped entities | quais Entities do estúdio têm o escopo da Room — exprimido por um predicado, não por um mecanismo novo |
As duas máquinas.
| De | Estados |
|---|---|
| uma Room | created → open → closed → torn down, onde torn down é terminal e closed quer dizer sem entradas novas, não sumida |
| uma associação | active ⇄ inactive → departed, com departed terminal para aquela associação |
O que vale para toda Room.
| Sempre | O que é |
|---|---|
losing a connection and leaving | sã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 replay | uma reconexão retoma a partir do estado da sessão; o módulo não promete os Events do intervalo |
a spectator | nã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 roles | um dono de Room é um Actor segurando um direito (Access & Roles), não uma patente na lista de membros |
presence | tem 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 two | a 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 |
Os dois modos de autoridade.
| Modo | Quem conduz o Tick |
|---|---|
| our simulation | a nossa implementação de Room e os módulos dela |
| external authority | um 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 identical | regras 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 move | nenhum 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.
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()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.
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();| Sempre | O 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) |
registration | aceita 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 reservations | Matchmaking 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 leave | um 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 journal | quem 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.
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.
| Se você quer dizer | Leia |
|---|---|
| qual cliente recebe qual estado, e quanto dele | Visibility, com Data e Prediction |
| qual máquina é dona da Entity, e o que acontece quando ela morre | What 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 em | Que nomeia | |
|---|---|---|
| quem vê o quê | o aspect — Data, Visibility | o 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 executa | o tipo de Room — Rooms | o 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.
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.
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.
| Broadcast | Pacotes por Actor | |
|---|---|---|
| Envia | a Room inteira, para todos | a cada jogador apenas a fatia que as regras dele selecionam |
| Serve para | uma Room pequena; este é o padrão | uma multidão, onde o tamanho do pacote precisa ser previsível |
| Lê a Declaration | uma vez, para a Room | por 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ê
| Actor | Nesta página |
|---|---|
schema-author | declara o predicado de visibilidade, o teto de objetos e a ordem dele, quais áreas vizinhas são visíveis, e o modo de entrega |
any | assina e recebe o que a zona admite; pode baixar o teto de objetos para si dentro dos limites declarados |
De relance
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 scopeAttribute-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
[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.
| Declara | O que é |
|---|---|
predicate | a 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 dele | uma 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 areas | se 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 mode | shared 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.
| Sempre | O que é |
|---|---|
visibility | nã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 recipient | pode 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 é
fnadm— 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.
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.
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.
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.
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
Joinjá cobrem isso.
Quem faz o quê
| Actor | Nesta página |
|---|---|
player | cria e cancela o próprio ticket, e entra como parte de uma party |
match-organizer | declara filas de matchmaker e o relaxamento delas; lê resultados de colocação |
backend-service | carimba critérios confiáveis antes do enfileiramento; executa decisões de matchmaker externo |
De relance
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Parte | O que é | Quem acredita nela |
|---|---|---|
self-description | as propriedades declaradas do participante — rating, modo, idioma, mapa escolhido | ninguém sem verificação: é a alegação de quem chama |
requirement | um predicado que os demais precisam satisfazer | a 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.
| Declara | O que é |
|---|---|
properties | por nome e tipo. Uma propriedade não declarada aqui é recusada num ticket como falha de validação em vez de ignorada |
roster size | um 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 ladder | um 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 language | a 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 |
mutuality | se um roster é aceitável quando A aceita B enquanto B não aceita A. Não há padrão |
ticket lifetime | depois do qual o ticket transiciona para expired com um Event |
outcome | RoomPlacement — 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.
| Sempre | O que é |
|---|---|
one live ticket per participant per queue | um 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 outcome | chega 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 ticket | declarado 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
expiredcom 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.
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.
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
RandomPositionbaseada em regras, sem atalho. - Arenas devem ser regeradas por partida — um
Seeddeclarado 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ê
| Actor | Nesta página |
|---|---|
schema-author | declara mapas, primitivos de obstáculo, destrutíveis, camadas e as regras delas |
room-owner | liga um mapa a uma Room; pede posições de spawn; faz raycasts |
operator | coloca ou remove obstáculos e camadas pelo painel |
De relance
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 assetAttribute-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
[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 assetScatter 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 repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});O modelo
Duas camadas, declaradas por pessoas diferentes.
| Camada | O que guarda, e quem a declara |
|---|---|
static | terreno com altura, primitivos de obstáculo, limites e locais — conteúdo autoral |
dynamic | obstá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.
| Declara | O que é |
|---|---|
key e version | um 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 |
terrain | um 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 |
obstacles | um 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áculo | intransponí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 |
bounds | o volume fora do qual uma posição é inadmissível |
world strata | estratos espaciais declarados dentro de um mapa — solo, subsolo, ar. São Declarations de geometria e de endereçamento |
locations | lugares 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 generator | opcionalmente, 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 stratum | uma Declaration dentro do mapa — solo, subsolo, ar |
map instance | uma 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.
| Sempre | O que é |
|---|---|
one geometric canon | toda 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 moment | uma 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 values | sã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 contact | nã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.
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.
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ê
| Actor | Nesta página |
|---|---|
room-owner | declara 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:
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
])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
[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.
| Declara | O que é |
|---|---|
shape | um 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 lives | num 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 checked | stepwise — 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 in | dentro de quais volumes ele é contado |
its relation to the art model | nenhuma é 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:
| Resposta | O que significa |
|---|---|
stop | o movimento cessa na última posição admissível |
slide | o movimento continua ao longo do obstáculo pela componente que for admissível |
bounce | a direção é refletida e a velocidade multiplicada por um coeficiente declarado |
damp | o movimento continua com a velocidade multiplicada por uma fração declarada |
pass | o obstáculo não afeta o movimento, mas o contato continua observável |
cease to exist | a 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.
| Sempre | O que é |
|---|---|
every pair | tem 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 platforms | a 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 fact | um 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 contact | antes 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 module | nã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 event | que é 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.
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.
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,Teleporte 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ê
| Actor | Nesta página |
|---|---|
schema-author | declara modelos de movimento, restrições e ligações |
room-owner | aplica impulso, teleporte e modificadores a partir do host |
player | envia 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
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)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
[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:
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateVerbos do servidor:
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)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.
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.
| Declara | O que é |
|---|---|
movement model | um 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 |
parameters | valores 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 |
limits | velocidade 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 input | stop, continue until a declared deadline ou continue indefinitely. Não existe padrão "siga como antes" — um jogador cuja rede caiu continuaria dirigindo |
pose tolerance | quã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 cap | com que frequência o passo roda, e quantos passos podem ser dados de uma vez quando o servidor está atrasado |
impulse kinds | cada um com a magnitude dele e o modo de decaimento dele |
O que vale para todo passo.
| Sempre | O que é |
|---|---|
the module owns | a 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 |
collisions | nã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 limit | faz 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 constraints | recuo, 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 bits | resultado 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.
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.
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:
ResolveAtrebobina 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ê
| Actor | Nesta página |
|---|---|
schema-author | declara campos preditos versus só autoritativos; define a janela de predição |
room-owner | resolve acertos num estado histórico; rebobina o mundo |
player | prediz 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.
| Mecanismo | O que faz | Roda em | Quando erra |
|---|---|---|---|
| Predizer o próprio movimento | aplica o modelo declarado à sua própria entrada sem esperar pelo servidor | o cliente | uma correção, reproduzida e suavizada |
| Mostrar outros jogadores | desenha outras Entities entre os estados que chegam | o cliente | um solavanco visível |
| Compensação de lag | rebobina os alvos ao momento que o atirador viu | o servidor | algué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.
| Preset | Prediz o seu | Compensa | Suaviza os outros |
|---|---|---|---|
| shooter | sim | numa janela de cerca de um segundo e meio | sim |
| arcade | sim | não | sim |
| observer | não | não | sim |
| sem predição | não | não | nã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.
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 predictedAttribute-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
[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":
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
var arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareO 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.
| Declara | O que é |
|---|---|
predictable aspects | quais deles o cliente pode avançar à frente da autoridade |
divergence threshold | abaixo 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 entities | interpolação entre estados chegados, ou extrapolação |
interpolation delay | quão atrás o desenho dos outros fica, declarado em vez de ajustado no feeling |
extrapolation window | além dela uma Entity é marcada como obsoleta e a extrapolação cessa |
compensation window | quão longe uma rebobinagem pode alcançar, e ela é managed |
what is rewound | posiçõ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 é | |
|---|---|
rewound | as posições e orientações dos alvos, e a geometria de obstáculos dinâmicos onde o tipo os declara históricos |
not rewound | estado de vida — os mortos não ressuscitam para levar tiro — e propriedade, pontuação e Inventory |
the rule behind the split | a decisão é tomada no passado; o efeito é aplicado no presente |
| Sempre | O que é |
|---|---|
authoritative state names the input it saw | ele 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 point | a mesma restrição de todo o resto do contrato |
one history ring, two consumers | a 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.
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
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í:
- Aceite o estado autoritativo.
- Reproduza as entradas armazenadas que vieram depois daquela que ele reconhece.
- 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.
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
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.
| Mecanismo | Roda em | Quando erra |
|---|---|---|
| predizer o próprio | o cliente | uma correção, reproduzida e suavizada |
| mostrar outros jogadores | o cliente | um solavanco visível |
| compensação de lag | o servidor | algué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.
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
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.
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.
Quando usar
- Os seus lobbies precisam ser preenchidos fora do horário de pico —
FillRoomcompleta 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
ConnectAsBotcomo 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ê
| Actor | Nesta página |
|---|---|
bot-brain | conecta como jogador; recebe percepção; envia comandos |
room-owner | declara perfis, preenche Rooms até a cota, transfere bot/humano |
De relance
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 inputsO 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.
| Declara | O que é |
|---|---|
thinking tick | com 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 brains | onde 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 preset | os direitos do bot, como um preset de Actor comum |
visibility of the bot marker | se 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 unavailable | um de três, sem padrão: do nothing · leave the room · fall back to built-in default behaviour |
roster filling | declarado 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.
| Sempre | O que é |
|---|---|
an actor, not a player | ele segura uma credencial de Actor mas não tem provedor de login, nem vínculos, nem sessões |
economic ownership | nã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 thoughts | vale 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 capacity | conta 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.
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ção | exatamente 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. |
| comandos | exatamente 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.
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.
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 —
Linkacrescenta 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
bannedque 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
authnã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ê
| Actor | Nesta página |
|---|---|
player | entra, vincula ou desvincula identidades, atualiza credenciais, sai |
moderator | revoga sessões; bane, suspende ou restaura jogadores |
backend-service | filtra o sign-in por região; semeia as primeiras linhas de um jogador novo; lê e revoga sessões |
De relance
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesCada ponto é customizado onde é declarado; as formas que um handler pode assumir estão reunidas em Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
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:
| Tipo | Quando o próprio handler falha | Na recusa |
|---|---|---|
| um portão | o passo é recusado — uma verificação de região inalcançável não é uma verificação de região aprovada | um 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 observador | o passo continua feito, então um pacote inicial que não chegou custa um baú, não o sign-in | ele 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
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.
| Sempre | O 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 player | uma segunda conta do mesmo provedor é conflito |
an external subject | nunca é o identificador de um jogador: ele pertence ao provedor, e usá-lo como nosso amarraria os nossos ids aos deles |
identity kind and access status | sã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 credential | sã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 sessions | cada uma revogada independentemente |
a credential's claims | são contexto declarado — região, locale — e apenas contexto. Um claim nunca carrega autoridade |
a device fingerprint | não é identidade: nunca é motivo para admitir, apenas motivo para recusar, e é armazenado e comparado de forma irreversível |
As três máquinas.
| De | Estados |
|---|---|
| o tipo de identidade | anonymous → registered, e a transição é de mão única |
| o status de acesso | active ⇄ suspended, e active → banned → active para um desbanimento |
| o jogador | alive → merged, onde merged é terminal: um jogador fundido não entra de novo |
O que o consumidor declara.
| Declara | O que é |
|---|---|
sign-in policy | se 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 role | 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 é uma Declaration legítima em vez de uma omissão |
session policy | o 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 policy | como 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.
| Sempre | O que é |
|---|---|
it is not self-promotion | conceder 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 idempotent | conceder 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 instant | e 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 + subjectestá 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.
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.
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_ide 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ê
| Actor | Nesta página |
|---|---|
schema-author | marca Entities como pertencentes a jogador e declara quais delas formam o Profile |
player | lê o próprio Profile; escritas vão para as próprias Entities |
room-visitor | lê 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-sideAttribute-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
[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.
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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 leavesO modelo
| Conceito | O que é |
|---|---|
player_id | toda a ideia que a plataforma tem de um jogador, mais o perfil de sistema por trás dele — Auth & Players |
| conjunto do Profile | as Entities pertencentes a jogador que o projeto declara como Profile dele; declará-lo é opcional |
| seleção por dono | a 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ública | aquela 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.
| Sempre | O que é |
|---|---|
there is no profile record | ele 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 lives | altere a linha progress, e toda leitura de Profile que a inclui enxerga o valor novo na passagem seguinte |
there is no public write | uma visão não tem onde escrever, e estado compartilhado gravável passa por código de servidor |
declaring the set is optional | e 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 predicate | nã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 hook | um 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 —
fnouadm. 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.
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.
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ê
| Actor | Pode | Não pode |
|---|---|---|
player | propor 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 entrada | ler a lista de relações de qualquer outra pessoa, sob qualquer relação de participante que seja |
moderator | decidir sobre convites e pedidos de entrada onde segurar o átomo de administração de associação | decidir 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.
| Declara | O que é |
|---|---|
kind | symmetric — 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 rule | depois de uma recusa: proibida · permitida depois de um período declarado · permitida de imediato. Declarada, porque "perguntar de novo" é decisão de produto |
presence visibility | um 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 broken | depois 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.
| Estado | Significado |
|---|---|
proposed | o iniciador propôs e o outro lado não respondeu |
mutual | os dois lados concordam |
declined | o outro lado recusou. A relação é mantida, porque a regra de reconvite precisa saber |
broken | um lado saiu de uma relação mútua |
blocked | um lado bloqueou o outro |
O que vale para toda relação.
| Sempre | O que é |
|---|---|
one entity per pair | nã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 dominates | dele 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 block | não o revela: a operação responde not found, então um Actor bloqueado não consegue descobrir o bloqueio sondando |
the block state | pertence 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 intent | nã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 group | manté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 overwrites | relaçõ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
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.
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ê
| Actor | Nesta página |
|---|---|
player | envia e recebe mensagens; lê histórico; silencia ou bloqueia |
moderator | filtra, redige e bane termos |
backend-service | envia ou agenda notificações com template |
De relance
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)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.
// 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")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.
[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:
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declara | O que é |
|---|---|
the group | a lista dela — um participante é um Actor, exatamente como em Groups |
binding to a lifetime | opcionalmente 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.
| Parte | De quem é |
|---|---|
envelope | da plataforma: o autor, a conversa, o momento pelo relógio declarado |
payload | do 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.
| De | Estados |
|---|---|
| uma conversa | created → active → closed |
| uma mensagem | sent → published | rejected by the filter, e então editada ou apagada, observavelmente |
| uma notificação | created → queued → delivered | expired |
O que vale para toda mensagem.
| Sempre | O que é |
|---|---|
order within a conversation | é estável e declarada. Ordem entre conversas não é prometida |
editing and deleting | sã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 |
history | sã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 window | o 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 payload | a 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 route | nã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.
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.
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
Grantdo comércio com origemreward(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ê
| Actor | Nesta página |
|---|---|
player | navega vitrines, compra, gerencia a carteira, resgata códigos |
seller | configura catálogo, preços e agendas de vitrine |
backend-service | valida recibos; reprecifica ou concede via Hooks de compra |
De relance
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declara | O que é |
|---|---|
key | ele é conteúdo autoral, endereçado por uma chave para que renomear em código seja renomear |
kind | consumable — é gasto; ou durable — possuído uma vez |
prices | um 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 provider | um espaço por provedor, declarado, porque uma loja conhece o item pelo id dela |
what it points at | opcionalmente 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 purchasable | um 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 third | um 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.
| Declara | O que é |
|---|---|
offers | o conjunto, cada uma apontando para um item |
schedule | em tempo de relógio, sempre UTC: quando a janela abre e fecha |
audience | um 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.
| De | Para |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid ou granted | refunded |
| Sempre | O 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 payment | tem 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 entitlement | carrega 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 entitlement | acumula: 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 secrets | moram no plano do operador, nunca na Declaration, e nunca num repositório |
a provider's capabilities | sã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.
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.
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ê
| Actor | Nesta página |
|---|---|
player | lê as próprias posses e gasta delas |
backend-service | concede, incrementa e revoga em nome de um jogador, nomeando o jogador por quem age |
De relance
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)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.
| Declara | O que é |
|---|---|
| um tipo com dono | a posse pertence a um dono, e seleção por dono é operação da própria Entity |
um ref para um item de catálogo | a 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 incremento | uma 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 dele | um 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.
| Sempre | O que é |
|---|---|
the cap has no default | as 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 immutable | nada 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 row | exibe 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.
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.
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ê
| Actor | Nesta página |
|---|---|
player | lê top-N/ao-meu-redor/posição própria, assina mudanças de posição |
backend-service | envia resultados; corrige ou rejeita no Hook de pré-envio; concede recompensas quando um ciclo fecha |
operator | declara placares; fecha um ciclo antecipadamente, corrige registros (auditado), acompanha taxas de envio |
De relance
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 itAttribute-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
[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:
Agg | Um segundo envio | Idempotente |
|---|---|---|
Set | substitui o registro pelos valores enviados | sim |
Best | substitui só quando os valores novos classificam mais alto pela chave de ordenação | sim |
Increment | soma os valores enviados ao registro — abates, voltas, contribuição de guilda | não — leve uma chave de idempotência |
Decrement | subtrai-os | nã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:
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 chamada | O que é |
|---|---|
PlayServ · playserv | o 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 submete | a 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 |
playerId | o 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:
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("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.
| Eixo | Valores | Como você define |
|---|---|---|
| Dono | jogador · Group | Owner = Owner.Player — um placar de guilda é o mesmo placar com Owner.Group |
| Chave de ordenação | um ou mais campos declarados, cada um crescente ou decrescente | [Rank(1, Sort.Descending)] int Score |
| Agregação | set · best · increment · decrement | Agg = Aggregation.Best |
| Reset | um agendamento em UTC; um ciclo expira, nunca apaga | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Quem pode enviar | só o servidor (o padrão) · jogadores | Submit = Submit.ServerOnly |
| Campos de exibição | declarados e tipados; nunca parte da ordenação | [Display] string Map |
| Lista de donos | escolhida no momento da leitura, não declarada | ForOwners("weekly-score", ids) — amigos, uma guilda, um lobby |
| Regras de torneio | janela de inscrição · máximo de participantes · tentativas por ciclo · exigir entrada | Rules = 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.
| Sempre | O que é |
|---|---|
direction and operator | sã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 generation | um segundo não é uma segunda linha |
an entry | nã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 order | eles são exibição, e é por isso que são declarados à parte |
a generation | expira, 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 |
O que é um ciclo, e o que fechar um faz.
| O que é | |
|---|---|
a reset | fecha um ciclo em vez de apagá-lo |
a closed cycle | para 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 event | carrega 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.
| Hook | O que pode fazer |
|---|---|
pre-submit | um 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-closed | um observer: disparado depois do fato, ele não pode vetar, e uma falha ali deixa o ciclo fechado |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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ção | Declarada como | Na fronteira |
|---|---|---|
| janela de inscrição | entryWindow: TimeSpan — por quanto tempo entrar continua aberto depois que o ciclo abre | uma entrada depois que ela fecha é recusada; o ciclo ainda corre até o reset dele |
| máximo de participantes | maxEntrants: int — registros num ciclo | o participante 65 de 64 é recusado como conflito, e nada é descartado — um placar que largasse as piores linhas classificaria quem chegou primeiro |
| tentativas por ciclo | attemptsPerCycle: int — envios por dono | o envio seguinte responde "tentativas esgotadas" — um conflito, não erro de permissão, e o contador reseta com o ciclo |
| exigir entrada | joinRequired: true — participantes são uma associação, não todo mundo que joga | um 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.
| Limite | Na fronteira | Número |
|---|---|---|
| linhas por leitura | a página é aparada, "há mais" continua verdadeiro, after: continua | teto de página definido por projeto |
| janela em torno de um dono | aparada simetricamente | teto de janela definido por projeto |
| registros num ciclo | o envio é recusado como conflito; sem descarte | maxEntrants por placar; ilimitado quando não definido |
| tentativas por dono por ciclo | conflito "tentativas esgotadas", limpo pelo reset | attemptsPerCycle por placar; ilimitado quando não definido |
| taxa de envio por dono | recusa por limite de taxa carregando o momento em que uma repetição é permitida | taxa definida por projeto |
| placares por projeto | uma Declaration nova é recusada no deploy | limite definido por projeto |
| retenção de ciclos fechados | o ciclo deixa o armazenamento com um Event; leituras então respondem not-found | janela 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.
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.
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ê
| Actor | Nesta página |
|---|---|
player | sobe pedaços, lê arquivos em fluxo, envia UGC |
moderator | revisa a fila, aprova ou rejeita envios |
backend-service | deriva variantes de asset; engancha upload e moderação; define cotas |
De relance
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clOs portões em torno dele são Hooks, o mesmo contrato de sempre:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
| Declara | O que é |
|---|---|
origin | conteúdo autoral, gerado pelo jogo, ou gerado por usuário — e os limites de tamanho e as políticas decorrem disso |
admissible content types | como lista declarada, nunca farejados dos bytes |
size limits | verificados quando a sessão é aberta, pelo tamanho declarado, em vez de na última parte |
part size and order | o upload é realizado por uma sessão: um tamanho de parte declarado, a ordem das partes, um ponto de retomada |
derivatives | opcionalmente, 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 prefix | no 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.
| Sempre | O 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 session | morre observavelmente: passado o prazo dela, ela é encerrada com um Event e as partes dela são liberadas |
ownership | segue 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 access | pode 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ãoforbidden— 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 —
expiredcom 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.
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.
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ê
| Actor | Pode | Não pode |
|---|---|---|
any actor | declarar tipos no schema; emitir em nome próprio, um a um ou em lote; ler os tipos declarados | preencher o contexto; ler, consultar ou agregar o que foi emitido |
backend-service | o mesmo, e emitir em nome de um jogador por delegação | ler telemetria — não há permissão de leitura, porque não há operação de leitura |
De relance
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))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.
[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.
| Declara | O que é |
|---|---|
name | o do próprio tipo |
fields | tipados pelo sistema de tipos da plataforma; uma máscara de campo se aplica a eles como em toda parte |
schema version | obrigató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 |
sampling | que 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 tolerance | se este tipo tolera perda. A telemetria é o único lugar do contrato onde perda declarada é legítima |
deletion behaviour | como 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.
| Sempre | O que é |
|---|---|
no addressing target | sem 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 share | viaja 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 |
emission | nã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 events | sã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.
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 SDK | O plano do operador | |
|---|---|---|
| Alcançado de | código de jogo | o painel de controle, o CLI, MCP |
| Guarda | Rooms · Entities · jogadores · comércio · Leaderboards | projetos e ambientes · deploy e rollback · faturamento · administração de organização e de usuários · roteamento de cluster |
| Trabalha em | Declarations, Hooks, Events, Operations | as 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 SDK | O que o operador vê |
|---|---|
| Schema as Code / Data & Subscriptions | Entities, migrações, o navegador de registros, visões salvas, importação/exportação |
| Entity | máquinas de estado, inspeção por instância |
| Entity Presets | tabelas de drop, definições de ability, Stat e projétil, presets de World Object — ajustáveis ao vivo |
| Access & Roles | a grade de papéis: papéis × operações, filtros de linha, máscaras de coluna |
| Extensibility | cadeias de cenário com sobrescritas, ordem resolvida, rastros de invocação |
| Rooms | a frota: Rooms, saúde do Tick, colocação, status de drenagem |
| Matchmaking | filas, tickets em voo, curvas de relaxamento |
| Catalog & Commerce | catálogo, agenda de vitrines, recibos, reembolsos |
| Leaderboards | ciclos, correção de registros (auditada), taxas de envio |
| Auth & Players | provedores, sessões, banimentos, o cenário de autenticação |
| Files & UGC | assets, filas de revisão de UGC, cotas |
| Analytics | painéis, encaminhadores, atraso de ingestão |
| Map | mapas e conjuntos de obstáculos, instâncias vivas |
| Visibility / Collision / Locomotion / Prediction & Lag Comp | ajuste por Room: regras, pares de resposta, janelas, custo de pacote por Actor |
| Groups / Messaging | navegador de Groups, templates, filtros de moderação, agendas |
| Bots | perfis, cotas de preenchimento, endpoints de cérebro |
| Inventory / Profile | posses 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.
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
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:
| Eixo | Faixa |
|---|---|
| Forma | dirigido por mensagem ou por requisição |
| Canais | de canal único ou multicanal |
| Estado | com recuperação de estado de conexão, ou sem |
| Protocolo | TCP 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ível | Significado |
|---|---|
at least once | reentregue até ser confirmada; o receptor tolera duplicatas |
at most once | enviada uma vez, nunca repetida; a perda é aceitável |
exactly once | deduplicada 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:
- Desabilitar a cadeia dependente. Todo módulo que precisa do ausente também é desligado, e as interfaces dele ficam ausentes em vez de falharem.
- 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.
PlayServ SDK
Игровой бэкенд, который поставляется вместе с геймплеем. PlayServ — это backend-as-a-service для живых игр: студия эксплуатирует бэкенд своей игры — данные, игроков, Rooms, Matchmaking, коммерцию — не хостя его. SDK — это то, как ваш код на сервере и в движке работает с этой платформой.
Эта страница — короткий список того, что здесь действительно иначе. Всё ниже решается один раз на проект и конфигурируется, а не пишется; то, что вы вызываете, живёт на модульных страницах, и каждый раздел здесь заканчивается указанием на владельца.
Симуляция — не ваш код
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 — самая короткая дорога внутрь.
С чего начать
Играбельная арена (карта, танки, стрельба, дроп), объявленная от начала до конца. Ниже нет игрового цикла: симуляция исполняется внутри платформы, и это весь код, который есть.
Прежде чем начать. Проект со средой dev (создаётся в операторском плане, которому принадлежит этот жизненный цикл), CLI playserv, залогиненный в него, и пакет SDK для вашего биндинга — больше в игру не устанавливается ничего.
Путь пользователя
Каждый вызов, который делаете вы, — это один из примеров ниже; шаги между ними — платформа, действующая по тому, что сказало Declaration. Ability, Stat и DropTable на схеме — Entity Presets, то есть Declarations на Entity, а не отдельные модули.
1. Объявить мир
Entity — это ваша схема плюс её живые аспекты. Один атрибут на поведение, рядом с полем, которое он описывает:
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)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
[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, Effect | Entity Presets, которые их используют |
EntryRequest, Verdict, StatEvent | payload'ы Hooks, их передаёт модуль, на который вы вешаетесь |
Seat | Matchmaking |
Scope, области синхронизации | Visibility |
Tick, частоты Tick | Rooms |
Перечисления закрытые. Правило, которое не покрывает ни один член, пишется предикатом, а не новым членом: [Aspect("loadout", Visible = "owner == caller.player")] — так выражается видимость по полю, когда Scope.Owner не совсем то правило, которое вы имели в виду (Data & Subscriptions).
2. Объявить Room
Шаблон Room'ы говорит, что такое сессия, и называет Declarations, на которые опирается. Наследовать класс Room'ы не надо, и метод Tick заполнять не надо, потому что внутренности Room'ы принадлежат платформе:
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)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
[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 — облачные функции, которые платформа вызывает на названных шагах. Типизированный вход, типизированный выход: никаких мешков с контекстом, никаких логгеров в подписи:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, достигший своего низа — 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. Движковые биндинги здесь первоклассные; серверные ведут ту же поверхность безголово (мозги бота, нагрузочный тест, инструмент эксплуатации):
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)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.
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).
Куда дальше
- Учитесь на примерах, раздел сразу после этого: Leaderboard в Tanks, аптечки в Tanks или рецепт ежедневного турнира для мета-цикла — по одной настоящей фиче каждый, и каждый шаг ссылается на модульную страницу, которой принадлежит только что использованное.
- Как работает SDK, когда форма SDK начинает значить больше, чем следующая фича: Основные понятия — словарь, а четыре статьи отвечают на кто вызывает (Авторитетность), как выражается право (Access & Roles), из чего сделан SDK (Как устроен SDK) и как он исполняется (Потоки, время жизни и тестирование).
- Затем модули. У каждой модульной страницы одна и та же анатомия — тезис, 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 |
| Разработчик выделенного сервера Unreal | Rooms (Hosting a room) → Bots → Locomotion · World Objects → Map → Что переживает потерю хоста |
Leaderboard в Tanks
У Tanks, показательной арены из Getting Started, Leaderboard нет. Этот урок — проходить его можно в любой момент после Getting Started — добавляет недельный Leaderboard по фрагам в три шага: объявить его, отправлять счёт из Hook на смерть, прочитать его в клиенте. Каждый шаг ссылается на модульную страницу, которой принадлежит только что использованное, поэтому урок учит указанием, а не повторением.
Шаг 1 — объявить Leaderboard
Leaderboard — это Declaration: какое поле его ранжирует, как складываются повторные отправки, когда он сбрасывается и кто вправе отправлять. Aggregation.Increment прибавляет каждую отправку к текущей сумме, поэтому один фраг — одно очко. Submit.ServerOnly стоит по умолчанию и закрывает Leaderboard от клиентов — именно это делает шаг 2 единственной дорогой внутрь.
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)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
[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 — облачная функция, типизированная на входе и на выходе, поэтому отправка фрага внутри него это одна строка.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 и окно вокруг локального игрока — пять строк выше, пять ниже и своя. Оба возвращают ранжированные записи с фрагами и отображаемым именем, готовые к привязке к списку. Подписка держит панель актуальной, пока идёт матч, и доставляет ранг только локального игрока.
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))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.
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, которую этот урок расширяет.
- Основные понятия — словарь, который предполагает каждая модульная страница.
Аптечки в Tanks
Это второй урок по Tanks. Три шага и ни одного нового модуля: Declaration ящика, Declaration того, где ящики появляются, и один Hook на то, что происходит при подборе. Проходить после Getting Started, в любом порядке с уроком про Leaderboard.
Шаг 1 — объявить ящик
Ящик — это Entity с двумя применёнными пресетами и телом, которое сообщает о контакте, никого не останавливая. Именно Response.Pass на слое pickups делает его подбираемым, а не препятствием: контакт сообщается, движение проходит сквозь.
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)])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
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, и это тот шаг, который решает, будет ли механика ощущаться честной. Разнос не даёт ящикам сбиваться в кучу, дистанция от игроков — появляться посреди дуэли, а правило неповторения не даёт одному и тому же месту быть ответом каждый раз.
[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)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
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, и это единственный код в уроке. Он исполняется на платформе облачной функцией — поэтому и не появляется ни на одной из вкладок движков.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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испускает Eventchanged, а клиент уже подписан на объявленные Stats танка. Сетевое сообщение писать не пришлось.
Владеют Extensibility и Entity Presets.
Что изменилось, а что нет
| До | После | |
|---|---|---|
| повреждённый танк | остаётся повреждённым до смерти | восстанавливается, перемещаясь по арене |
| код Room'ы | нет | по-прежнему нет |
| новые смонтированные модули | — | ни одного: два Declaration и один Hook |
| исключения в анти-чите | — | ни одного: лечение авторитетно на сервере, как любая правка Stat |
Куда дальше
- Entity Presets — генератор дропа, World Objects и модель Stats, на которые опирался урок; все три — пресеты
entity, а не модули. - Collision — слои, отклики и разница между сообщённым контактом и блокирующим.
- Map — как выбирается допустимая позиция и что значит «достижимая».
- Extensibility — все точки Hooks по порядку, с контрактом на вето.
- Leaderboard в Tanks — второй пример по Tanks.
Ежедневный турнир
Что получится: ежедневный турнир с окном заявок, засеянными Rooms и выплатой призов — целиком из Declarations и Hooks на модулях, у которых уже есть свои страницы. Ни одного нового понятия здесь нет; это Leaderboards, Matchmaking, Rooms, Catalog & Commerce и Messaging, сложенные под один мета-цикл.
Шаг 1 — объявить Leaderboard с окном заявок и лимитом попыток
Турнир — это обычное Declaration Leaderboard'а плюс ограничения участия: окно заявок, лимит числа участников и попытки за цикл. В подсчёте не меняется ничего — ключ порядка, агрегация и сброс остаются ровно теми же, что на любом Leaderboard'е.
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)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
[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 засеивают матч — тот же путь «размещение и место», которым идёт каждый матч, только в области очереди турнира.
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)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.
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) — последнее, что исполняется, держа в руках итоговое состояние матча, и отправка идёт оттуда.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 в шаблон.
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 остаются как есть.
Куда дальше
- Leaderboards — турнирные оси: окно заявок, лимит участников, попытки.
- Matchmaking → Rooms — пати, размещение и засеивание.
- Extensibility → Catalog & Commerce → Messaging — цепочка Hooks, которая платит.
- Основные понятия — словарь, который предполагает каждая модульная страница.
Основные понятия
Слова, которыми остальные страницы пользуются, не останавливаясь на объяснениях. Модульная страница исходит из того, что вы уже знаете, что такое Actor, аспект или Room, — здесь у каждого однострочное определение и ссылка на страницу, где механизм за ним живёт на самом деле. Прочитайте один раз перед модульным справочником — или возвращайтесь, когда слово окажется тяжелее, чем вы ожидали.
Три вещи слишком велики для статьи, и у каждой своя страница: Авторитетность — кто вызывает и что одно это решает; Как устроен SDK — из чего SDK сделан; Наследование и композиция — как модули строятся друг на друге. В этом порядке они читаются как одно рассуждение.
Четыре поверхности
Каждый модуль выставляет ровно четыре вещи, и каждая модульная страница построена вокруг них. Это и есть программная модель:
| Поверхность | Что означает |
|---|---|
| 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.
Авторитетность
Авторитетность — это абстракция, а не две сборки 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.
Access & Roles
Роли складываются, а не зашиваются. Атомарные разрешения складываются в роли; роли ограничивают данные вплоть до строки и столбца и решают, какие интерфейсы модулей сборка вообще видит. Это заменяет деление ключей на клиентские и серверные: учётные данные называют личность, а их роли разрешаются на каждом запросе.
Когда применять
- Нужны учётные данные уже, чем «клиент» или «сервер», — за ними на каждом запросе разрешаются составные роли.
- Доступ к данным обязан останавливаться на строках и столбцах: ограничение по региону, маски PII, подрядчики только на чтение.
- Сборка должна видеть только интерфейсы, которые открывает её роль, — kick/close у посетителя попросту нет.
- Интерфейс обязан гасить кнопки честно —
CanIвычисляет ту же политику, которую применит сервер. - Не нужно, если поставляемые пресеты (
player,room-owner,seller, …) уже совпадают с вашими Actors — каждый модуль уважает их по умолчанию; полный каталог живёт на Основных понятиях.
Кто что делает
| Actor | На этой странице |
|---|---|
operator | объявляет роли и политики, задаёт лимиты по строкам и столбцам, выдаёт роли, выпускает ключи |
match-organizer | турнирный персонал из потока ниже: держит составной ключ, пропускает заявки, вернуть деньги не может |
every actor | проверяет CanI перед действием; видит только свои открытые интерфейсы |
Одним взглядом
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()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 |
Кто выдаёт роль. Выдача и отзыв роли у игрока, а также объявленная проектом роль по умолчанию для нового — это Operations на Auth & Players: тот модуль владеет личностями, а роль разрешается по личности в учётных данных. Эта страница владеет тем, что роль такое; та — передачей её из рук в руки.
Ошибки
- То, что скрывает предикат, отвечает
not found, а неforbidden— иначе сам отказ сообщает вызывающему, что вещь существует, а ровно от этого её и прятали. - Право, которого вызывающий не держит, отвечает
forbiddenтам, где существование субъекта не секрет, и называет, чего не хватило, а не падает молча. - Поле вне маски отсутствует в ответе, а не присутствует пустым: пустое значение и замаскированное были бы неразличимы.
- Роль, включающая саму себя — напрямую или по цепочке, — ошибка конфигурации: отклоняется как Declaration, а не разрешается в рантайме.
- Делегирование никогда не расширяет возможности: вызов, который Actor не мог сделать от своего имени, отклоняется и когда сделан от имени игрока.
Ограничения
Каждый потолок называет своё поведение на границе; сами числа приедут с главой об ограничениях платформы.
- Размер выборки под предикатом строки ограничен, и модель ACL объявляет эту границу, а не обнаруживает её. Чтение сверх потолка получает потолочное количество строк и маркер, говорящий, что список урезан, — но никогда молча укороченную страницу.
- Граница устаревания разрешённого права объявлена, и отзыв её не выжидает — он обесценивает сразу.
Путь пользователя
Один ключ организатора турнира, от сложения роли до живой смены прав.
Как устроен SDK
Два вопроса путают друг с другом, и у обоих короткие ответы. Как SDK написан — почему одна и та же мысль выглядит чуть иначе на Python и на Unreal C++. Как SDK работает — что стоит между вашим вызовом и проводом. Эта страница отвечает на оба один раз, чтобы этого не пришлось делать ни одной модульной странице.
Написан от общего к частному
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.
- Модули складываются, а не наследуются. Как именно и что здесь честно означает «наследование» — Наследование и композиция.
Потоки, время жизни и тестирование
Цикл ваш. Мы доставляем ровно в одно место и никогда за вашей спиной. SDK не запускает ни одного потока, о котором вам пришлось бы знать, не выдаёт вам ни одного лока и вызывает ваш код из единого контекста, который вы выбрали на старте. Зовите нас с любого потока, какой вам нравится; мы зовём вас с одного.
Один контекст доставки, и цикл ваш
Экземпляр объявляет ровно один контекст доставки — единственное место, где исполняются все его обработчики. Он фиксируется при инициализации и не меняется всю жизнь экземпляра. Event, Delta данных, исход вызова — всё приезжает туда и больше никуда.
Форм у него две, и вы выбираете одну на старте:
- Качаете вы. Рантайм сам по себе не делает ничего; вы вычерпываете ожидающие доставки из собственного цикла. Это форма, которую хочет движок: доставки приземляются на игровой поток, в выбранном вами кадре.
- Владеем мы. Рантайм держит один выделенный поток исполнения. Это форма, которую хочет консольный хост или выделенный сервер.
Ни одна не запасная для другой, и третьего варианта с пулом потоков нет. Смысл обещания про один контекст в том и состоит, что вам никогда не придётся спрашивать, сколько потоков мы наделали.
Контекст никогда не аргумент. Ни один обработчик не принимает параметр «на каком я потоке», и спросить не у кого. Где исполняется ваш обработчик — свойство контракта, а не данные вызова.
Старт и остановка явные
Инициализация — это ваш вызов, и он отвечает исходом. Ничто не инициализируется лениво при первом использовании — это запрещено, а не просто не рекомендовано, и причина стоит предложения: ленивый старт переносит единственное место, где виден выключенный модуль, в тот произвольный вызов, который случился первым, — и там он читается как падение этого вызова.
Выключенный модуль называется на старте, и исход говорит, что из двух случилось: вся зависимая цепочка выключена — или вы работаете с меньшим, плюс список того, что недоступно. Молчаливого третьего случая нет.
// 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, idempotentAttribute-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.
// 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 свободен от привязки к потоку, и это обещание, а не свойство сегодняшней сборки. Вы никогда не возьмёте наш лок, не подождёте на нашем барьере и не услышите, что что-то надо звать «под локом»: примитивов синхронизации в поверхности нет вообще.
Обработчики одного экземпляра сериализованы: два никогда не исполняются одновременно, а порядок внутри одного потока сохраняется. Поэтому обработчику не нужны собственные блокировки.
Сериализовано не значит дедуплицировано. Порядок — одно обещание; сколько раз доставлено сообщение — другое, объявленное на типе сообщения. При «хотя бы один раз» вы увидите одно и то же сообщение дважды, и ключ дедупликации, который всегда едет вместе с ним, — то, чем вы это отличите.
Начать операцию изнутри обработчика законно и не может привести к дедлоку. Но её исход никогда не приезжает внутрь того же обработчика — он возвращается отдельной доставкой на тот же контекст. Идти вглубь можно; разворачиваться внутри — нельзя.
Блокировать контекст доставки запрещено, и запрет этот не совет. Ждать сеть, ждать чужой лок, синхронно ждать собственный вызов — всё запрещено внутри обработчика. У запрета есть симптом: обработчик, который держит контекст сверх своего объявленного бюджета, порождает либо объявленную деградацию доставки, либо объявленный отказ. Чего он не порождает никогда — так это молчаливого замедления, которое вам предстоит обнаружить в сессии игрока.
// 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 downAttribute-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.
// 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, поэтому ничто не появляется и не исчезает в другом месте из-за того, что вы поставили рядом.
Если на опциональный блок ссылаются, а загрузиться он не может, это объявленный исход инициализации — то же место, где сообщается о выключенном модуле. Никогда не заглушка, которая молча ничего не делает.
Каждый биндинг объявляет минимальную версию рантайма, под которую собран. Ниже неё вы получаете отказ на инициализации, а не частичную работу: слишком старый рантайм иначе ломается на первой же возможности, которой ему не хватает, — а это где-то произвольно в вашем коде и обычно на машине игрока, а не на вашей. Поднятие этого минимума — ломающее изменение и проходит тот же процесс, что и любое другое.
Куда дальше
- Getting Started — первая Room, от начала до конца.
- Как устроен SDK — почему поверхность одна и как складываются части.
- Под капотом — слой под этим, если любопытно.
Core
Core — единственный объект, который вы создаёте, и всё остальное висит на нём. Один ключ на входе — и у вас есть контекст, личность, типизированные отказы, трассировка, батчи. Через него проходит вызов каждого модуля, и ни один модуль не поставляет свою версию.
Когда применять
- Нужно знать, кто вы и где — личность, роли, открытые модули, project · env · регион, всё на одном объекте, который вы держите.
- Облачная функция должна писать как игрок — запись приписывается тому игроку, а зафиксированы обе стороны: и функция, и игрок.
- Повторы не должны применяться дважды — батчевые Operations несут ключ идемпотентности.
- Отказ должен быть ветвимым и находимым поиском — каждый бросок это типизированный
Problemсо стабильным кодом. - Не нужно, если вам нужны сообщения, вызовы или состояние — это Primitives: Events, RPC, Data & Subscriptions.
Кто что делает
| Actor | На этой странице |
|---|---|
any actor | читает личность, контекст и роли через Whoami |
backend-service | действует как игрок; батчит идемпотентные Operations |
operator | читает трассы упавших и повторённых вызовов |
Одним взглядом
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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 | она ответила отказом, неся код из каталога платформы |
local | SDK отказал до отправки, из собственного опубликованного словаря |
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. Семантика монтирования — пространства имён, отклонение коллизий в момент монтирования — живёт на Под капотом.
Ошибки
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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 не выставляет наружу ни действующего значения, ни оставшегося запаса, ни предупреждения о приближении, и ничто про лимит никогда не показывается игроку. Отказ несёт всё, что есть:
- категорию, а она и говорит, осмыслен ли повтор вообще
- чей это был лимит
- когда разрешён повтор и на каком окне
Ветвитесь по ним. Счётчика для опроса нет, и бюджета для показа нет.
Путь пользователя
Один упавший вызов, от броска до трассы, которую читает оператор.
Events
Event — это факт того, что нечто случилось, доставленный всем, кто должен об этом услышать. Применяйте для того, что случается один раз и что нельзя нагнать из текущего значения: выстрел, покупка, вход в Room'у.
Когда применять
- Нечто случилось, и другие обязаны отреагировать: выстрел, запертая дверь, закончившийся матч.
- Аудитория меняется — тот же emit доходит до отряда, до Room'ы или до одного Actor, и решает это target, который объявляет тип.
- Хочется типизированных обработчиков с автодополнением — объявленный Event становится
send.иon.на своей поверхности, у каждого свой контракт. - Факт должен читаться и через час — объявите тип удерживаемым и читайте его обратно по периоду.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет Events через [Event], отправляет схему |
any actor | испускает через send., подписывается через on. |
Одним взглядом
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))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 |
clock | sim_time или timestamp, никогда оба: sim_time для фактов внутри симуляции, которые участвуют в Prediction, компенсации лага и откате; timestamp для фактов вне неё, вроде покупки или входа |
retention | transient — доходит до тех, кто подписан в момент 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, поэтому один и тот же тип всегда хранится одинаково и ни одному вызывающему не приходится помнить, какой вызов каким был.
| Transient | Retained | |
|---|---|---|
| Доходит до | тех, кто подписан в этот момент | до них же и до подписчика, пришедшего позже |
| После | исчез | хранится объявленный срок |
| Читается обратно | нет | да, в пределах срока |
| За сроком | — | выборка отказывает, а не отвечает пустым |
[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)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 до маркера, который каждый участник отряда видит на своём экране.
RPC
Типизированный вызов, чьё тело живёт где-то ещё. RPC — второй Primitive: объявите процедуру там, где ей место — на модуле или внутри Entity, — и каждый биндинг получит сгенерированный метод, который можно ждать. Глагол один — invoke: односторонность это режим, который называет Declaration, а не второй глагол, и никакого do нет.
Когда применять
- Вызывающему нужен ответ — запрос/ответ с типизированным возвратом.
- Вызывающий сообщает и идёт дальше — объявленный односторонний RPC, назад не едет ничего.
- Работа переживает вызов — объявленный отложенный RPC отдаёт дескриптор работы вместо таймаута.
- Один вопрос, много отвечающих — групповой вызов это N вызовов, и каждый ответ приезжает привязанным к отправившему его участнику.
- Глагол принадлежит вещи — объявите его внутри Entity; RPC у Entity не живёт больше нигде (Entity показывает Declaration).
- Не нужно, если никого не просят действовать: факт, на который другие просто реагируют, — это Event.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет RPC, их режимы и то, кому дозволено их звать |
any actor | вызывает отвечающий или односторонний вызов там, где Declaration это допускает |
group member | отвечает на веерный вызов; назад едет по одному ответу на участника |
Одним взглядом
[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)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 mode | with a reply — значение объявленного выходного типа или типизированный отказ; или one-way — ответа нет, и вызывающий узнаёт только о локальном сбое отправки. Односторонний нельзя применять там, где вызывающему нужен исход: неизвестный исход стоит дороже известного отказа |
execution mode | immediate — исход возвращается внутри вызова; или deferred — вызов возвращает дескриптор работы, а исход читается или приезжает подпиской. Объявляется, а не выбирается реализацией по нагрузке, потому что вызывающий строит своё поведение на форме ответа |
streaming | приходят ли вход и выход частями и обрабатываются ли по мере прихода, а не целиком |
idempotency | односторонний RPC тоже несёт ключ идемпотентности: отсутствие ответа не означает отсутствия повторной доставки |
overridability | объявляется на самом методе. Нет Declaration — значит не переопределяем; переопределяемости по умолчанию не бывает |
context | где он объявлен. RPC, объявленный внутри Entity, — часть этой Entity и вне её не существует. Объявить его в игровом сервере и есть регистрация в роутере: второго способа добавить его нет |
Что несёт вызов.
| Несёт | Что это |
|---|---|
arguments | только то, что вызывающий обязан выбрать |
implicit context | получатель, вызывающий и окружающий контекст, привязанные до вашего первого написанного параметра, — у метода Entity никогда не спрашивают идентификатор этой Entity |
references | аргумент, который является объектом SDK, едет типизированным Ref — идентификатором или курсором, а не копией содержимого. Получатель разрешает его от своего имени, под теми же разрешениями и предикатами: ссылка это адрес, а не выданное разрешение |
outcome | значение объявленного выходного типа или типизированный Problem |
Что держит дескриптор отложенного вызова.
| Держит | Что это |
|---|---|
state | accepted → 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.
[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 ranexport 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 ranAttribute-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 — новый отклоняется, летящие доигрываются.
- Время жизни дескриптора — за ним исход недоступен, и это отказ.
- Глубина цепочки вызовов — при превышении объявленный отказ, а не исчерпанные ресурсы и не молчаливый обрыв.
Путь пользователя
Один отправленный счёт, один сообщённый пинг, один отряд, у которого спросили, готов ли он.
Data & Subscriptions
Вы меняете одно поле. Всё ниже по течению происходит без единой строки кода. Data — третий Primitive: механика под каждым синхронизируемым полем — Deltas против последнего подтверждённого состояния, аспект как единица политики, приоритет и частота отправки, возобновляемые подписки, удерживаемое окно и Hooks до и после изменения.
Вы обращаетесь к Entity, а не к таблицам — поверхность чтения и изменения см. в Entity (найти, отфильтровать, отсортировать, разбить на страницы, подписаться на выборку); эта страница — механика под ней. Потребительского пути к таблице нет, и второго способа писать нет: изменение — это Operation у Entity, а Delta — то, что из него следует.
Когда применять
- Нужно состояние, реплицированное на клиенты без кода снимков, — изменение поля и есть вся синхронизация.
- Поля различаются срочностью или аудиторией — приоритет и потолок частоты отправки на аспект, плюс предикат видимости для тумана войны.
- Переподключающийся клиент не должен молча разойтись — разрыв обнаруживается и называется, а разрыв за удерживаемым окном получает в ответ полное состояние.
- Нужно недавнее прошлое — удерживаемое окно Deltas, индексированное по
sim_time, — это то, что читают Prediction и компенсация лага. - Правилу валидации место в одном месте — Hook до изменения срезает или накладывает вето, прежде чем изменение приземлится.
- Крутилки не нужны, если всё, что вам надо, — читать или запрашивать: поверхность Entity едет на этой механике, не прикасаясь к ней.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет аспекты, их политику синхронизации и предикат видимости |
any actor | подписывается на target; возобновляется с позиции; запрашивает полное состояние |
backend-service | Hooks до и после изменения |
operator | читает стоимость пакета на Actor; видит, когда доставка деградирует или пакет режется |
Одним взглядом
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 mode | shared packet — одно и то же всем, дёшево по CPU; или per-actor packet — каждому своё по его зоне видимости, дорого по CPU и необходимо на больших населениях |
visibility rule | предикат, решающий, кто вообще получает, — эту половину целиком проецирует Visibility |
Что держит подписка.
| Держит | Что это |
|---|---|
target | экземпляр, выборка или аспект; она получает Deltas этого target. Target — не поток: один target может покрывать много пар, а порядок обещан внутри пары, а не поперёк target |
position | откуда она возобновляется: её предъявляет потребитель. Если разрыв больше удерживаемого окна, вместо потока Deltas приезжает полное состояние, поэтому долгое отключение никогда не оставляет клиента молча неправым |
state | active → gap detected → resynchronised | closed, и closed терминально |
Что верно про любой поток.
| Всегда | Что это |
|---|---|
merging | Deltas его допускают: 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 и когда.
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 — при исчерпании объявленная деградация к общему пакету.
Путь пользователя
Одно изменение позиции, от присваивания до исправленного движения на каждом экране.
Groups
Один список, один массовый слушатель. Group — четвёртый Primitive: именованный набор Actors, который получает как один. Вы обращаетесь к Group'е, и слышит каждый участник — Room, чат, пул Matchmaking и список рассылки это один и тот же Primitive под разными правилами: разная логика входа и выхода, разное время жизни, один и тот же список внизу.
Когда применять
- Нужны пати, отряды или гильдии — именованные наборы игроков с объявленной вместимостью и, где тип её объявляет, со временем жизни.
- Членство должно следовать объявленному правилу, которое вычисляет платформа, — новые ветераны попадают внутрь без крона и без вашего собственного вызова «пересчитать».
- Хочется обратиться сразу ко многим игрокам: объявленный Event расходится веером через
send.*, объявленный RPC достаёт каждого участника, и каждый ответ приходит именованным. - Нужна одна модель членства, переиспользуемая как аудитория — область Visibility, разговор Messaging, пати Matchmaking.
- Не заводите свою, если набор — это участники одной сессии: Rooms и есть этот Primitive с правилами Room'ы, и он их уже адресует.
Кто что делает
| Actor | На этой странице |
|---|---|
player | создаёт Groups из объявленных типов, входит и выходит, добавляет и удаляет участников, шлёт Events, вызывает веерные RPC; держа право администрирования членства в Group'е, удаляет участников и закрывает её |
room-owner | правила мест в Room'е едут на этом Primitive (настраивается в Rooms) |
backend-service | объявляет типы Groups и их правила; вешает Hooks на вход и выход |
Одним взглядом
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 groupAttribute-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'ы разойтись не могут.
[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: ...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() };
[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 mode | explicit — участник добавляется и удаляется действием; или 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 — один массовый слушатель |
states | created → 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 приносит по ответу на участника, и отряд встаёт в очередь как единое целое.
Extensibility
Каждый сценарий платформы — цепочка зарегистрированных функций. Замените звено или оберните его. Именно это конкретно означает «настраиваемая платформа», и именно это стоит вместо открытого кода: вы заменяете собственные шаги платформы своими, поэтому наши исходники вам не нужны.
Когда применять
- Шаг платформы обязан исполнять вашу логику — объявите замену для названного звена через
[Override(…)]. - Нужны проверки или побочные эффекты вокруг шага — упорядоченный
Before/Aftermiddleware, который может наложить вето или уведомить. - Код должен исполняться по расписанию, по Event или по вебхуку — триггеры отдают вам разобранный типизированный контекст.
- Надо знать, что реально исполнится, до деплоя — прогоните цепочку вхолостую и прочитайте разрешённый порядок.
- Не нужно, если правило касается записей одной Entity: Hook из Data & Subscriptions — форма полегче.
Кто что делает
| Actor | На этой странице |
|---|---|
backend-service | переопределяет звенья, оборачивает шаги middleware, пишет обработчики триггеров |
operator | осматривает цепочки, задаёт порядок, читает секреты, прогоняет разрешение вхолостую |
Одним взглядом
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Модель
К чему платформа даёт вам прицепиться.
| Термин | Что это |
|---|---|
registered function | один переопределяемый шаг платформы — «создать профиль», «разрешить цену» |
scenario | упорядоченная цепочка, которую исполняет поток платформы: вход, join, покупка |
overridability | можно ли звено заменить, только обернуть или оно фиксировано |
middleware | упорядоченный до/после обработчик вокруг звена |
trigger | то, что запускает ваш код: Event, расписание, вебхук |
secret | значение, которое ваш обработчик вправе прочитать |
invocation | один прогон, со своей трассой |
Что объявляет Hook.
| Объявляет | Что это |
|---|---|
position | названный шаг, к которому он цепляется |
kind | gatekeeper — проверка допуска или валидация, и он падает закрытым, поэтому шаг не исполняется, когда ломается сам Hook; или observer — лог, уведомление, счётчик, и он падает открытым: шаг исполняется, а о сбое всё равно сообщают, а не проглатывают его. Значения по умолчанию нет |
moment | before — перед валидацией, получает типизированный 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 sees | Events, которые сценарий испускает после, потому что override и middleware исполняются на платформе, а движковый рантайм — не место, чтобы их хостить. Именно это имеют в виду вкладки @na у примеров на этой странице, говоря о подписке на получившиеся Events |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Возьмите, измените, поставьте. «Проприетарный открытый код» — это рабочий процесс, а не лозунг: собственная логика платформы — это функции, которые можно вытянуть, отредактировать и развернуть заново:
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 рисует ту же покупку со своей стороны.
Цепочку с overrides и middleware можно разрешить и прочитать до того, как что-либо исполнится. Разрешённый порядок осматривается в панели и из кода.
Уроки и рецепты: Leaderboard в Tanks пользуется Hooks этого модуля; ежедневный турнир проходит через этот модуль.
Schema as Code
Объявите модель в коде, отправьте её, получите типы обратно. Дорога разработчика в схему: админ-панель и код пишут одну и ту же модель, а кодогенерация замыкает круг для каждого движка.
Когда применять
- Ваша модель данных должна жить в коде и ревьюиться как код — объявить,
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: отправляет на мерж и следом перегенерирует движковые типы |
Одним взглядом
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 = 0Attribute-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
[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 | стабильное имя, по которому к нему обращаются. Переименование в коде — это переименование, а не удаление с созданием |
kind | Entity, part или enum, объявленные в коде |
ownership mode | seed — код создаёт запись, если её нет, а повторный 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 в коде до перегенерированных движковых типов.
Entity
Модуль, на который опирается всё остальное. Entity — это объявление схемы, выращенное живыми аспектами: данные 0..*, состояния 0..*, RPC 0..*, Events 0..*, Hooks и история изменений. Map привязывает препятствия к Entities, Collision привязывает аспект трансформа, Stats и есть пресет, World Objects — пресет плюс машина состояний.
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, читает состояние |
Одним взглядом
[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()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();
};
[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:
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"))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.
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 — этот экземпляр, — поэтому слышат все подписанные на дверь, и никакой список получателей с отправкой не едет.
Модель
Танк, дверь, полоска 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'е |
time | AfterSeconds на состоянии — объявленный триггер, а не корутина: он идёт по часам симуляции Room'ы, продвигается с sim_time, стоит, пока Room не симулируется, а удаление экземпляра заканчивает его машины вместе с их ожидающими таймерами |
Что может выборка.
| Ось | Что допустимо |
|---|---|
filter и sort | только объявленные поля — handle таблицы не существует, а выборка адресуется по Entity и ограничена Room'ой или проектом |
include | объявленный ref, втягиваемый вместе со страницей |
paging | по непрозрачному курсору: не смещение, не идентификатор строки, и его смысл не переживает смену версии. Возвращайте его обратно, никогда не разбирайте |
access | предикаты применяются до разбиения на страницы, поэтому страница никогда не несёт дыр там, где были бы скрытые строки |
live | подписка на выборку держит её живой: участники входят и выходят по мере изменения их данных |
// 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()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.
// 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:
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})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.
// 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 до звона, который слышит игрок.
Наследование и композиция
Модули строятся друг на друге, и ничего из этого не наследование классов. Базового модуля, от которого наследуются, нет, и иерархии, которую расширяют, тоже — модули образуют граф. Эта страница о том, что здесь честно означает «наследование», и о шести механизмах, которые делают работу вместо него.
Что здесь означает наследование
Это слово покрывает четыре разных механизма, и их стоит развести по именам.
- 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.
Entity Presets
Пресет — именованный набор аспектов Entity — данные, состояния, RPC, Events, Hooks, — упакованный под один игровой случай. Пресет применяют, его числа крутят или выводят свой. Применение добавляет аспекты вашему типу; ваш тип оно ни подо что не подкладывает — пресет не модуль, и наследовать в нём нечего. Stats, Abilities, Projectiles, таблицы дропа и World Objects — это пять пресетов, а не пять подсистем: то же Declaration, та же синхронизация, тот же порядок Hooks.
Когда применять
- Вещь в вашей игре несёт числа, которые срезаются по границам, восстанавливаются и запускают переход на своих границах.
- Действию нужны стоимость, откат, фазы и эффекты, достижимые из одного клиентского глагола.
- Нечто уходит в полёт, и его попадание должно судиться честно для стрелка с лагом.
- Лут обязан приходить из взвешенных шансов, которые переигрываются в точности, когда игрок спорит о дропе.
- На карте есть мебель — двери, кнопки, ловушки, разрушаемое — с состояниями, которые обязаны пережить вход посреди раунда.
- Пресеты не нужны, когда Entity — это просто синхронизируемые данные. Объявите поля и остановитесь.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет Stats, Abilities, Projectiles, таблицы дропа, World Objects |
room-owner | крутит числа пресетов, бросает таблицы дропа, создаёт World Objects |
player | применяет способности, стреляет, подбирает лут, взаимодействует с объектами |
Одним взглядом
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})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.
// 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);Модель
Пресет не вводит новых понятий. Всё, что он добавляет, выразимо средствами, которые 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, — и ни один из них не модуль, который вы монтируете.
Rooms
Room — это игровая сессия; платформе всё равно, что её хостит. Одна абстракция покрывает выделенный сервер на матч, одну большую общую карту, разрезанную на логические слои, Room под master-client и мини-игру, хостящуюся на бэкенде. Внутренности Room'ы наши; вы ведёте Room'у снаружи.
Когда применять
- В вашей игре есть сессии — матчи, лобби, подземелья, гонки, — и что-то обязано владеть их жизненным циклом, членством и переподключениями.
- Вы хостите на выделенных серверах, на master-client игрока или на самом бэкенде, и игроков надо туда маршрутизировать.
- Одна общая карта обязана вести много логических сессий — слои, ограниченные Visibility.
- Игроки входят посреди сессии и обязаны увидеть текущую правду — состояние Room'ы на входе, затем живой трафик.
- Оборванное соединение не должно стоить места — льготное окно шаблона (45 с в
battle) возобновляет то же членство. - Не нужно, если фича — чистый запрос/ответ над записями: Data & Subscriptions её уже покрывает.
Кто что делает
| Actor | На этой странице |
|---|---|
room-owner | регистрирует Rooms по всему процессу; на одном экземпляре Room'ы — админский интерфейс на экземпляр: правит живую конфигурацию, кикает, запирает, вещает, распускает |
entry-validator | принимает или отклоняет запросы на вход с кодом и причиной |
room-visitor | листает, входит с данными, переподключается в льготном окне, выходит |
spectator | входит, не участвуя в состязании; получает вещание и живой трафик |
match-organizer | резервирует места, которые засчитываются в вместимость; бронь истекает по сроку шаблона (90 с в battle) |
Одним взглядом
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 longAttribute-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
[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: зарегистрировать, хостить несколько на процесс, править живую конфигурацию, кикать, публиковать, распускать.
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
Клиент:
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))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.
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 mode | our 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 | переподключение возобновляется из состояния сессии; Events разрыва модуль не обещает |
a spectator | не вырожденный участник: присутствует, не занимает места и не входит в состав, к которому обращаются как к «игрокам», — иначе каждая операция над составом несла бы условие |
no in-room roles | владелец Room'ы — это Actor, держащий право (Access & Roles), а не звание в списке участников |
presence | имеет историю, состав — нет: кто вошёл, отвалился, вернулся и ушёл, сохраняется; изменения состава — не вторая история |
the interface | принадлежит конкретной Room'е: вы обращаетесь к этой Room'е, а не только к её типу |
three axes, not two | API поверх всех Rooms (листать, регистрировать, перечислять); API на Room'у, который зовёт любой участник (войти, выйти); и админский интерфейс на экземпляр — кикнуть, запереть, поправить конфигурацию, закрыть, распустить эту Room'у, — открытый тому, кто держит админскую или хостовую роль для этого экземпляра, а не членством |
Два режима авторитетности.
| Режим | Кто ведёт 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'ы.
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()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.
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 reservations | Matchmaking забирает место на срок брони шаблона — 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, показывающего, кто присоединился.
Кто что видит и какая машина это ведёт
Два вопроса, которые звучат как один. Кто что видит — про клиента: какой срез состояния Room'ы доезжает до какого игрока. Какая машина это ведёт — про хост: какой процесс владеет Entity и какой будет владеть следующим. Слово, которое их смешивает, — replication: в игровом движке оно обычно называет первый вопрос, а здесь — второй.
| Вы имеете в виду | Читайте |
|---|---|
| какой клиент получает какое состояние и сколько его | Visibility, вместе с Data и Prediction |
| какая машина владеет Entity и что происходит, когда она умирает | What Survives Losing a Host |
Они объявляются в двух разных местах
Ни то ни другое не настраивается в рантайме, и общей Declaration у них нет.
| Объявляется на | Что называет | |
|---|---|---|
| кто что видит | аспекте — Data, Visibility | предикат видимости, потолок объектов и его порядок, какие соседние области видны, и режим доставки |
| какая машина это ведёт | типе Room'ы — Rooms | режим авторитета, насколько внешнему авторитету верят про исход, и поведение при падении хоста |
Различаются они и тем, что происходит, если не сказать ничего. Аспект без собственного правила видимости доставляется в общем пакете — это умолчание, и для маленькой Room'ы оно верное. Тип Room'ы, не назвавший режим авторитета, отклоняется: умолчания нет, потому что выбрать между нашей симуляцией и внешней за вас никто не может.
Visibility
На сорока игроках снимок всей Room'ы нормален. На двухстах — нет. Зона видимости решает, кто что получает, объявленным предикатом, а не тумблером, который вы щёлкаете на объекте. Широковещание и пакеты на Actor — два объявленных режима доставки одной модели, поэтому переход между ними это конфигурация, а не переписывание. Это оптимизация канала, а не разрешение — за ним см. Access & Roles.
Одна объявленная модель — предикат, слои, ярусы детализации — читается двумя способами. Переход между ними это конфигурация, а не переписывание, потому что оба являются прочтениями одного Declaration.
| Широковещание | Пакеты на Actor | |
|---|---|---|
| Отправляет | всю Room'у всем | каждому игроку только тот срез, который выбирают его правила |
| Подходит | маленькой Room'е; это значение по умолчанию | толпе, где размер пакета обязан оставаться предсказуемым |
| Читает Declaration | один раз, на Room'у | на каждого Actor |
То, что не должно утечь, отсутствует в пакете, а не спрятано на клиенте: не отправлено вовсе — и это делает его свойством безопасности, а не полосы.
Когда применять
- Ваши Rooms переросли широковещание на всю Room'у — двумстам игрокам нужны поклиентские потоки окрестностей, а не каждая Delta.
- Состояние не должно утекать: туман войны и поля только для владельца должны быть не отправлены, а не спрятаны на клиенте.
- Несколько сессий делят одну карту и не должны видеть друг друга — слой это ещё один предикат.
- Размер пакета обязан быть предсказуем в толпе — ограничьте число объектов и объявите порядок, чтобы «ближайшие N» были обещанием, а не случайностью плотности.
- Игрок на границе обязан видеть за неё — объявите, какие соседние области видны, потому что по умолчанию видна только своя, и граница иначе читается как стена пустоты.
- Не нужно, если Room маленькая: режим доставки общим пакетом её уже покрывает.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет предикат видимости, потолок объектов и его порядок, какие соседние области видны и режим доставки |
any | подписывается и получает то, что зона допускает; может опустить потолок объектов для себя в объявленных границах |
Одним взглядом
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 scopeAttribute-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
[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 mode | shared 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 — это
fnadm: облачная функция или панель, но никогда клиент, спрашивающий, сколько стоит за ним наблюдать.
Ограничения
Каждый потолок называет своё поведение на границе; числа за ними приедут с главой об ограничениях платформы.
- Стоимость пакета на Actor — при исчерпании объявленная деградация к общему пакету с уведомлением, а не произвольная потеря получателей.
- Объектов на правило — ограничены объявленным порядком и наблюдаемым флагом обрезания.
- Подписок на Actor — новая отклоняется, существующие продолжаются.
- Размер Delta — Delta разрезается, а не обрезается, и разрез наблюдаем.
- Частота отправки — верхняя граница, а не гарантия.
Путь пользователя
Правило радиуса превращает Room'у на 200 игроков в поклиентские потоки окрестностей.
«Кто это видит?» и «что они видят?» — оба запрашиваемы, потому что дорога та отладочная сессия, в которой вы не можете на них ответить. Стоимость пакета на Actor — полноправное чтение, и в коде, и в панели.
Что переживает потерю хоста
Хост умирает посреди матча. Матч — нет. Эта страница про второе значение слова «репликация» — какая машина владеет Entity и какая будет владеть следующей. Первое значение, какой клиент получает какое состояние, — это Visibility вместе с Data & Subscriptions и Prediction & Lag Comp. Кто что видит и какая машина это ведёт — место, где эти двое разводятся.
Состояние Room'ы не копируется между хостами
У Entity ровно один владелец за раз, и никакая вторая машина не держит живую копию, готовую перехватить.
Две копии, принимающие один и тот же выстрел, обязаны были бы договориться о порядке, в котором приземлились два выстрела. Договариваться о порядке тридцать раз в секунду между машинами — это консенсус, а консенсус кладёт задержку ровно туда, где игра её не потерпит. У единственного владельца такой проблемы нет, и каждый механизм ниже существует, чтобы сделать единственного владельца переживаемым, а не чтобы его обойти.
Что реплицируется — так это присутствие: какой Actor на каком узле. Это маленький и медленно меняющийся факт, поэтому маршрутизация может знать его повсюду, не платя за согласие о чём-либо движущемся.
Объявленное состояние хранится вне хоста
Объявленное состояние не является частным делом процесса, который его держит. Оно снимается с объявленным интервалом, поэтому замена может продолжить с последнего снимка, когда предыдущий хост перестаёт отвечать, а игрок заходит заново через обычное льготное окно Rooms.
Отсюда три следствия, и это честная форма происходящего:
- У замены состояние целиком, но по состоянию на снимок. Полное, а не текущее. Отказ стоит игры между последним снимком и потерей, и именно интервал фиксирует этот худший случай.
- Непрерывность Tick через смену авторитета не переносится. Перемещение, которое выполняет платформа, сохраняет состояние Tick участника; замена авторитета этого не обещает. Rooms — место, где объявлены оба, вместе с тем, что происходит, когда льготное окно проходит.
- Всё, что вы держали только в акторах движка, уходит вместе с процессом. Оно никогда не было объявлено, поэтому за пределами того хоста его не было ни у кого.
Деплой — тот же путь, минус потеря
Слить хост — перестать размещать на нём новые Rooms, дать летящим сессиям доиграть или передаться, а затем отпустить — это путь отказоустойчивости, запущенный намеренно и с предупреждением. Поэтому деплой без убийства живых сессий — не второй механизм, который надо построить и которому надо доверять: это тот же самый, запущенный нарочно, а не крахом.
Хост Room'ы узнаёт об этом так же, как узнаёт что угодно: платформа заранее уведомляет, что Room'у предстоит закрыть или передать по причине на её собственной стороне.
Что происходит, когда окно проходит, объявляется, и значения по умолчанию нет. Тип Room'ы, чей авторитет живёт вне платформы, называет один из трёх исходов его потери: выждать объявленное окно, закрыть Room'у или допустить авторитет-замену. Промолчать Declaration не предлагает, потому что альтернатива — тот самый сбой, ради предотвращения которого оно существует: Room с мёртвым авторитетом, которая всё ещё принимает входы и держит места, показывая каждому участнику живую сессию, в которой ничего не происходит.
Какая машина — не часть вашей поверхности
Вы никогда не называете узел. Тот, кто создаёт Room'у, не выбирает, где она исполняется, и ни одна операция не принимает хост аргументом: размещение принадлежит платформе и остаётся ей, чтобы она могла переместить Room'у, а ваш код не был написан против того, где она была раньше.
Если вы хостите Rooms сами — выделенным сервером или master-client, — верно то же самое с одной добавкой: вам говорят сворачиваться, и доиграть или передать свои сессии внутри льготного окна — ваше дело. Rooms — место, где хост регистрируется для этого биндинга, а Авторитетность — почему хост держит только те права, которые ему выдали.
Matchmaking
Довести игрока до нужной Room'ы. Тикеты описывают игрока и фильтруют остальных. Матчмейкер разрешает размещение, бронирует место, и дальше игровой трафик идёт прямо в Room'у.
Матчмейкер стоит в пути один раз, чтобы решить, где вам место. В пути матча его нет: его исход — это размещение и ограниченная по времени бронь места, а с момента входа игровой трафик идёт прямо в Room'у. Поэтому загруженная очередь никогда не превращается в загруженную игру.
Когда применять
- Игроков надо маршрутизировать в Rooms по объявленным критериям — режим, регион, ранг, — а не по самодельному списку лобби.
- Критерии матча обязаны приходить из данных платформы, а не из заявления клиента: штампуйте ранг в Hook перед постановкой в очередь.
- Очереди должны расширяться со временем на сервере, пока клиент держит один тикет и никогда не опрашивает.
- Пати обязаны попасть в один матч вместе — Group входит целиком или не входит вовсе.
- У вас внешний матчмейкер, и нужно только, чтобы его решение завершилось размещением и бронью места.
- Не нужно, если игроки выбирают сессию сами: браузер Rooms и
Joinэто уже покрывают.
Кто что делает
| Actor | На этой странице |
|---|---|
player | создаёт и отменяет собственный тикет и входит в составе пати |
match-organizer | объявляет очереди матчмейкера и их ослабление; читает результаты размещения |
backend-service | штампует доверенные критерии перед постановкой в очередь; исполняет решения внешнего матчмейкера |
Одним взглядом
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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 перед постановкой в очередь:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 |
outcome | RoomPlacement — ссылка на 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, потому что у тикета есть владелец: без сессии ставить в очередь некого.
Map
Статический мир: границы, ландшафт, препятствия и «куда что может встать?». Физическая модель намеренно гораздо проще визуальной: примитивы с footprint и высотой, слои с правилами и один запрос допустимой позиции, которым пользуется каждый другой модуль.
Когда применять
- Нужен статический мир — границы, ландшафт, препятствия, — который сервер может запрашивать, а не только отрисовывать.
- Спавны, дроп и декорации обязаны падать в законные места: один запрос
RandomPositionпо правилам, без обходных путей. - Арены должны генерироваться заново на каждый матч — объявленный
Seedвоспроизводит ту же карту в баг-репорте. - Ящики и стены ломаются и возвращаются — разрушаемое с HP и таймерами респавна.
- Ботам и Projectiles нужны ответы про рейкаст и линию видимости против набора препятствий.
- Не нужно, если мир чисто визуальный и никакой серверный код не спрашивает, куда что может встать.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет карты, примитивы препятствий, разрушаемое, слои и их правила |
room-owner | привязывает карту к Room'е; просит позиции спавна; делает рейкасты |
operator | ставит или снимает препятствия и слои из панели |
Одним взглядом
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 assetAttribute-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
[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 assetScatter и Destructible — генераторы размещения, а не броски в рантайме. Генератор разрешается, когда версия карты публикуется: сорок камней становятся сорока объявленными примитивами, и опубликованная версия несёт примитивы, а не правило. Поэтому один и тот же Seed даёт те же сорок камней в матче, в реплее и в баг-репорте, а геометрические лимиты проверяются один раз, на этом разрешённом наборе, до того как версия доедет до среды.
Запрос, который задают все остальные:
RandomPosition: a fair spawn on ground, away from players, never repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});Модель
Два слоя, объявляемые разными людьми.
| Слой | Что держит и кто его объявляет |
|---|---|
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 stratum | Declaration внутри карты — земля, подземелье, воздух |
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, а не модуль, который вы монтируете.
Collision
Привяжите трансформ к карте препятствий; объявите, что делает контакт. Collision исполняется внутри симуляции платформы. Вы объявляете тела, слои и отклики и подписываетесь на контакты.
Когда применять
- Движущиеся Entities обязаны разрешать контакты на сервере — скользить, останавливаться, отскакивать — без рукописной процедуры отклонения.
- Геймплей реагирует на касание: подбираемое собирается при перекрытии, объёмы-триггеры запускают машину состояний Entity.
- Locomotion и Projectiles нуждаются в заметённом разрешении против набора препятствий Map.
- Предпросмотру размещения или прицеливанию нужны «влезет ли сюда?» и запросы перекрытия объёмов.
- Не нужно, если ничто физически не встречается: геймплей «запрос/ответ» над записями — это простые Data & Subscriptions.
Кто что делает
| Actor | На этой странице |
|---|---|
room-owner | объявляет тела, слои и отклики; запрашивает перекрытия и контакты |
К каким Room'ам это относится. Модуль исполняется там, где симуляцию шагает платформа, — в Room'ах, объявленных с Host = "Backend". Если симуляцией владеет ваш собственный game server (PlayServ как метасервер), движение, коллизии и предсказание остаются на стороне движка, а эта страница описывает размещённую на платформе альтернативу, а не требование.
Одним взглядом
Форма, слой и то, что делает контакт, — всё сидит на самом теле; никто не объявляет пары слоёв издалека:
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
])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
[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 checked | stepwise — проверяется итоговая позиция шага, быстро, и быстрое тело проходит сквозь тонкое препятствие; или swept — проверяется отрезок между позициями, дороже, и туннелирование внутри шага исключено. Объявляется, никогда не выбирается реализацией по скорости: может ли снаряд пролететь сквозь стену — свойство игры, а не оптимизация |
areas it participates in | внутри каких объёмов оно считается |
its relation to the art model | никакого не требуется: тело — это упрощение, и расхождение с художественной моделью допустимо в объявленных границах |
Отклик объявляется на паре — род проходимости препятствия × тип тела — и берётся из закрытого набора:
| Отклик | Что означает |
|---|---|
stop | движение прекращается на последней допустимой позиции |
slide | движение продолжается вдоль препятствия той компонентой, которая допустима |
bounce | направление отражается, а скорость умножается на объявленный коэффициент |
damp | движение продолжается со скоростью, умноженной на объявленную долю |
pass | препятствие не влияет на движение, но контакт всё равно наблюдаем |
cease to exist | Entity заканчивается — снаряд об стену |
Коэффициенты — объявленные значения, а не вычисленные из масс и материалов: ни того ни другого в этом контракте нет.
Что верно про любую проверку.
| Всегда | Что это |
|---|---|
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, а не модули, которые вы монтируете.
Locomotion
Вы объявляете, как вещь движется; интегратор не пишет никто. Модель движения превращает пронумерованный ввод в авторитетное движение, интегрированное с Collision, записанное для Prediction & Lag Comp и изменяемое баффами, дебаффами и ландшафтом.
Когда применять
- Entities движутся по вводу игрока — танки, персонажи, техника, — и движение обязано быть авторитетным на сервере.
- Вы предпочтёте объявить скорость, ускорение и лимиты скорости поворота, чем писать интегратор.
- Геймплей толкает тела: отбрасывание через
Impulse,Teleportи модификаторы вроде грязи с длительностями. - Движение обязано ощущаться мгновенным: та же объявленная модель шагает на сервере и в цикле Prediction & Lag Comp.
- Не нужно, если позиции меняются только дискретными шагами: синхронизируемое поле на Entity это уже покрывает.
Кто что делает
| Actor | На этой странице |
|---|---|
schema-author | объявляет модели движения, ограничения и привязки |
room-owner | применяет импульс, телепорт и модификаторы с хоста |
player | подаёт пронумерованный ввод; читает состояние движения |
К каким Room'ам это относится. Модуль исполняется там, где симуляцию шагает платформа, — в Room'ах, объявленных с Host = "Backend". Если симуляцией владеет ваш собственный game server (PlayServ как метасервер), движение, коллизии и предсказание остаются на стороне движка, а эта страница описывает размещённую на платформе альтернативу, а не требование.
Одним взглядом
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)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
[Entity("tank")]
public class Tank
{
[Sync] public Vector3 Position;
[Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
public Motion Motion;
}Ввод клиента — это пронумерованное намерение. Движение шагает платформа:
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateСерверные глаголы:
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)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.
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 input | stop, 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, а не модули, которые вы монтируете.
Prediction & Lag Comp
Игрок нажал прыжок 50 мс назад. Пакет приехал только сейчас. Он не упал. Предсказание вперёд и компенсация назад над данными, которые несут своё настоящее время события: клиенту мгновенно, сервер остаётся прав, а попадания судятся во временной линии стрелка.
Когда применять
- Ввод обязан ощущаться мгновенным под задержкой, пока сервер остаётся авторитетным, — предсказывать вперёд, сверяться при расхождении.
- Попадания обязаны судиться во временной линии стрелка:
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 применяет их раньше или читает назад; второй копии он не объявляет никогда.
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 predictedAttribute-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
[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
}Разрешение с компенсацией лага отвечает на вопрос «где все были, когда этот выстрел был сделан»:
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, поэтому обе стороны рисуют одну и ту же дугу из одних входов:
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
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, а не модули, которые вы монтируете.
Предсказание собственного движения
Это собственная машина клиента, и из трёх механизмов предсказания только у неё ошибки дёшевы. Вы действуете по собственному вводу до того, как сервер ответил, сервер отвечает, и там, где двое расходятся, ваш клиент поправляет себя. Ошибка здесь стоит небольшой визуальной поправки — ровно поэтому здесь безопасно быть агрессивным.
Предсказание — это повторение объявленных правил, а не вторая копия
Ваш клиент не исполняет параллельную реализацию вашего движения. Он исполняет ту же объявленную модель, которую исполняет платформа, — модель принадлежит Locomotion, а предсказание лишь применяет её раньше. Это и есть вся причина, по которой две стороны согласны большую часть времени: набор правил один, применяется дважды.
А значит, нет ни операции «предсказать», которую надо звать, ни операции «поправить». Предсказание происходит потому, что аспект был объявлен предсказуемым, а не потому, что вы что-то вызвали.
Что предсказывается — объявляется на аспекте
Предсказуемость — это Declaration на аспекте Entity, и это намеренно не глобальный тумблер:
- Аспект, который клиент может вычислить — позиция под вашим собственным вводом, — предсказывать можно.
- Аспект, который авторитет меняет по правилам, которых у клиента нет, предсказывать нельзя. Если клиент не может его вывести, догадка порождает откат, который игрок читает как враньё игры.
Эта линия — то место, где вы решаете, чему дозволено мигать, а что обязано быть верным с первого раза.
Протокол поправки и два числа, которые его формируют
Авторитетное состояние приезжает, неся номер последнего применённого им ввода, поэтому ваш клиент точно знает, сколько его собственного буфера ещё не подтверждено. Дальше:
- Принять авторитетное состояние.
- Переиграть буферизованный ввод, пришедший после подтверждённого.
- Свести результат с тем, что вы уже показывали.
Два объявленных числа решают, как это ощущается. Порог расхождения: ниже него поправка сглаживается, выше — ваш клиент щёлкает и переигрывает. И граница буфера неподтверждённого ввода: переполнение не является неопределённым — деградация объявлена и наблюдаема, поэтому клиент на плохом соединении знает, что перестал предсказывать, а не дрейфует тихо.
Расхождение наблюдаемо тому клиенту, у которого оно было, и только ему. Вы можете понять, что ваше предсказание поправили и насколько, — это полезно для настройки и для честного индикатора связи игроку. Прочитать чужое расхождение вы не можете: величина ошибки предсказания — информация об их соединении, а не об игре. Поправка живёт на стороне клиента: состояние авторитета — это то, что всем остальным уже показывали.
Если вы приехали откуда-то ещё
- Mover 2.0 в Unreal. Форма знакома: ввод со штампами Tick, модель движения, поправки от авторитета. Разница в том, где живёт модель: здесь вы её объявляете, а симулирует платформа, — поэтому нашего компонента движения, который можно унаследовать или заменить, для вас не существует.
- Netcode с откатом и переигрыванием, как в Photon Fusion. Переигрывание собственного неподтверждённого ввода после поправки — тот же механизм, и он здесь целиком. Чего здесь намеренно нет — так это переисполнения мира задним числом; см. Компенсацию лага: что происходит вместо и почему.
Что это не покрывает
Entities других игроков не предсказываются, а показываются — это Показ других игроков. Судить выстрел во временной линии стрелка — серверный механизм, и он живёт в Компенсации лага. И ни один из трёх не действует вообще, когда режим авторитетности Room'ы внешний: тогда Tick принадлежит тому, кто его ведёт, и предсказание тоже.
Показ других игроков
Других игроков не предсказывает никто — их показывают. Вы получаете их состояние с интервалами и обязаны что-то нарисовать в промежутке. Ошибка здесь не стоит никому жизни; она стоит видимого рывка — поэтому у неё собственные Declarations, а не общие с предсказанием.
Режим показа объявляется, а не угадывается
Для Entities, которые не ваши, Room объявляет, чем заполнять промежуток между приходящими состояниями: интерполировать между теми, что есть, или экстраполировать за самое свежее. Это Declaration на Entity, поэтому ответ одинаков на каждом клиенте и не дрейфует вместе с тем, кто реализовал рендерер.
Задержка интерполяции тоже объявляется. Показывать других плавно означает показывать их слегка с опозданием, на объявленную величину. Назвать число — в этом и смысл: неназванная задержка — это баг-репорт, который вы не воспроизведёте, а названная — дизайнерское решение, которое можно крутить под свой жанр.
Экстраполяция останавливается, а не выдумывает
Окно экстраполяции объявлено, и за ним Entity перестаёт показываться движущейся, а не едет дальше по догадке. Экстраполировать бесконечно — значит посадить игрока стрелять по цели, которой там никогда не было, и заметить это он не может; видимая заморозка — та поломка, из которой есть выход.
Почему это отдельно от предсказания собственного
У трёх механизмов предсказания разные авторитеты и разные способы ломаться, и одно слово на все три означает, что настройка одного молча меняет два других.
| Механизм | Исполняется на | Когда ошибается |
|---|---|---|
| предсказание собственного | клиент | поправка, переигранная и сглаженная |
| показ других игроков | клиент | видимый рывок |
| компенсация лага | сервер | кто-то умирает несправедливо |
Поэтому же существует пресет observer, несущий этот механизм и больше ничего: у наблюдателя нет собственного ввода, который надо предсказывать, поэтому дать ему настройки предсказания означало бы настраивать то, чего он не делает.
Компенсация лага
Это серверный механизм, и из трёх единственный, чьи ошибки кого-то убивают. Когда он решает неверно, игрок умирает несправедливо — и в пользу того, у кого соединение хуже. Всё на этой странице сформировано этой асимметрией.
Вопрос, на который он отвечает, узок: что стрелок на самом деле видел? Действие может нести время обзора — Tick, на который Actor смотрел, когда действовал, — и платформа восстанавливает позы целей на этот Tick, поэтому выстрел судится против того, что было на его экране.
Время обзора — заявление, а не факт
Оно приезжает от клиента, значит это заявление вызывающего, и обращаются с ним соответственно. Два следствия:
- Окно компенсации ограничено, и вне его платформа отказывает. Экстраполировать из услужливости она не станет. Отказ — это решение, которое вы видите; молчаливая экстраполяция — решение, которого вы не видите.
- Чтение прошлого состояния цели всё равно подчиняется видимости. Спросить об историческом Tick — не способ обойти Visibility: чего вы не могли видеть тогда, того не прочитаете сейчас.
А «попадание не засчитано» — это вердикт, а не ошибка: успешный ответ с машиночитаемой причиной. Ваш код задал законный вопрос и получил законное нет.
Что откатывается — объявлено, и это не всё
Откатывать всё звучит непротиворечиво и порождает двойные убийства: двое стреляют друг в друга, обоих отматывают к моменту, когда оба живы, оба попадают. Не откатывать ничего отменяет саму компенсацию лага. Граница между ними — объявленный список, а не интуиция реализации.
Сама отмотка относится сюда, а не к тем модулям, которые отматываются. Кольцо истории восстанавливает позы спорного Tick, а затем Collision задают обычный для него вопрос о перекрытии этих поз — собственной истории Collision не держит, и ничто в нём не знает, что такое Tick обзора. Само кольцо — это трек истории Entity, а не второе хранилище.
Решение принимается по прошлому; эффект применяется в настоящем
Компенсация лага отвечает на вопрос о моменте обзора стрелка. Последствия — урон, смерть, начисление — применяются к текущему состоянию. То, что случилось между моментом обзора и моментом решения, не отменяется и не пересчитывается.
Поэтому наблюдаемо и задумано следующее: игрок может успеть выстрелить после того, как был убит чужим отмотанным выстрелом. Отменить это означало бы переигрывать мир поверх отмотки, которая воспроизводимости не обещает, — то есть производить расхождение вместо того, чтобы его убирать.
Пересимуляция на сервере намеренно не входит в объём модуля. Пересчёт последствий против новой правды требует неподвижной точки отсчёта, которой состояние с плавающей точкой нам не даёт. Остаётся всё, на чём модуль стоит: клиент, переигрывающий собственный неподтверждённый ввод (Предсказание собственного движения), и компенсация лага как чтение прошлого ради одного решения. Именно так на практике работает «в пользу стрелка».
Если вы приехали откуда-то ещё
- Компенсация лага в пользу стрелка, как её поставляет большинство соревновательных шутеров: тот же механизм, и эта страница — он.
- Полный rollback netcode. Отмотка здесь; переигрывания мира после неё нет, и абзац выше объясняет почему. Если ваш дизайн зависит от того, что последствия пересчитываются задним числом, эту зависимость стоит поднять с нами рано, а не обнаружить поздно.
Что ещё стоит знать
- Реализацию можно переопределить. Если вашей игре нужно другое правило компенсации, наше можно заменить, и замена объявляет, какие из Declarations она соблюдает.
- На пути предсказания и поправки точек расширения нет. Они исполняются с частотой Tick, и Hook в этом цикле был бы Hook, который вы не можете себе позволить.
- Ничего из этого не действует при внешнем авторитете. Компенсация лага существует для Rooms, которые ведёт наша симуляция. Когда Tick принадлежит игровому серверу студии или master-client, компенсация принадлежит тому, кто его ведёт; см. Кто ведёт Tick.
Bots
Бот входит как обычный игрок. Где-то ещё живут только мозги. Та же сессия, та же валидация входа, те же правила, тот же ACL. Room по построению не может отличить, поэтому боты прогоняют ваши настоящие правила игры, а анти-читу никогда не нужно исключение под бота.
Когда применять
- Ваши лобби надо заполнять в непиковые часы —
FillRoomдобивает матчи до квоты, и боты уступают места по мере прихода людей. - Боты обязаны играть по настоящим правилам — валидация входа, ACL, Visibility, — чтобы анти-читу никогда не понадобилось исключение под бота.
- Вы приносите внешние мозги — обученную политику, сервис, — которые входят через
ConnectAsBot, как любой игрок. - Entity отвалившегося игрока обязана передаться боту и обратно при переподключении так, чтобы этого не заметили ни место, ни Prediction & Lag Comp.
- Не нужно, если персонаж ничего не решает: диалоговый NPC без мозгов живёт в World Objects.
Эта страница — подключающая половина. Как завести бота в Room'у, как заполнить лобби до квоты, как передать место между ботом и человеком. Написание того, что решает, — другая половина: Как написать мозги, где специфицирован разъём, в который мозги втыкаются.
Кто что делает
| Actor | На этой странице |
|---|---|
bot-brain | подключается как игрок; получает восприятие; шлёт команды |
room-owner | объявляет профили, заполняет Rooms до квоты, передаёт бот↔человек |
Одним взглядом
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 всё это время исполняется по настоящим правилам.
Как написать мозги
Мозги — это обычный код, отвечающий на один вопрос: что бот делает дальше. Они исполняются там, где вы захотите — облачная функция, ваш собственный сервис, безголовый клиент, — и разговаривают с Room'ой через ту же поверхность, которой пользуется клиент живого игрока. Эта страница специфицирует разъём, в который они втыкаются: что мозги получают, что им дозволено слать обратно и когда. Bots покрывает другую половину: как завести бота в Room'у.
Что решено и против чего можно строить уже сегодня
Платформа не поставляет игрового ИИ. Ни деревьев поведения, ни utility-системы, ни навигационных мозгов. Это не дыра, ждущая заполнения, — это граница. Решения ваши, а работа модуля в том, чтобы сделать ваши решения неотличимыми от игроцких.
Мозги — не Hook. Hook оборачивает наш шаг. Мозги не являются нашим шагом вовсе: они исполняются вне Room'ы, по собственному расписанию, и платформе всё равно, с какой стороны было открыто соединение. Поэтому мозги могут быть облачной функцией, сервисом, который хостите вы, или безголовым клиентом, — и поэтому ни один из этих вариантов не «роднее» остальных.
Разъём — восприятие внутрь, команды наружу, и обе стороны намеренно игроцкие:
| Что это | |
|---|---|
| восприятие | ровно то, что получил бы игрок на этом месте, — те же Deltas, через те же правила Visibility. Бот не может смотреть сквозь стены больше, чем может игрок. |
| команды | ровно то, что слал бы игрок на этом месте. Привилегированного канала ввода не существует. |
Если игре действительно нужен бот, который видит больше — отладочный режим, тренировочный, — это объявленное расширение, а не побочный эффект того, что он бот.
Thinking tick объявляется, и это не Tick симуляции. Мозги снаружи, поэтому думают в собственном ритме. Между двумя мыслями действует последняя команда — и это самое важное, под что надо проектировать, потому что медленно думающие мозги дают не стоящего на месте бота, а бота, который продолжает делать то, что решил в прошлый раз.
У ухода мозгов объявленное поведение, и умолчания нет. Вы говорите, что происходит, когда мозги перестают отвечать, на тип Room'ы. «Мозги недоступны» — удерживаемый Event, поэтому поздний подписчик узнаёт текущую ситуацию, а не только будущие изменения.
Чем бот намеренно быть не может
Стоит прочитать до того, как проектировать вокруг, потому что это отказы, а не пропуски.
- Бот не игрок, и он не владеет ни entitlements, ни покупками, ни записями Leaderboard. Бот, который мог бы их держать, был бы способом их изготавливать.
- Флаг бота существует всегда и всегда наблюдаем платформе. Показывает ли его ваша игра игрокам — ваше решение; существует ли он — нет.
- Модуль не хранит истории того, что бот решил и почему. Это ваше дело, в вашей телеметрии: мы не собираемся становиться местом, где хранятся рассуждения вашего ИИ.
Auth & Players
Вход — переопределяемый шаг, а не чёрный ящик. Провайдеры, сессии, привязка личностей, баны. Каждая точка потока — до и после входа, до и после привязки, до и после слияния, на смене статуса — объявленная точка расширения с объявленным родом: ворота, которые могут отказать в шаге, или наблюдатель, который не может.
Когда применять
- Игроки обязаны входить — устройство, почта, Apple, Google, Steam или собственный провайдер — с созданием при первом входе как флагом, а не вторым потоком.
- Гостевой аккаунт обязан позже повышаться —
Linkдобавляет Steam с сохранённым прогрессом, а слияния сводят два аккаунта в одного игрока. - Политика обязана исполняться там, где её нельзя пропустить, — региональные ворота перед входом, стартовый набор после входа, который создал игрока.
- Модерации нужны зубы — отозвать сессии, приостановить, забанить устройство, с Event
banned, который слышат все живые системы разом. - Объявленный контекст (регион, платформа, сборка) обязан доезжать до каждого следующего Hook так, чтобы каждому не приходилось перечитывать игрока ради этого.
- Перейти сюда «полегче» не на что: каждый другой модуль называет своего вызывающего через этот, и
authнельзя выключить, пока хоть кому-то из них нужен Actor-игрок: конфигуратор модулей откажет и назовёт зависящих.
Кто что делает
| Actor | На этой странице |
|---|---|
player | входит, привязывает или отвязывает личности, обновляет учётные данные, выходит |
moderator | отзывает сессии; банит, приостанавливает или восстанавливает игроков |
backend-service | пропускает вход по региону; засевает первые строки нового игрока; читает и отзывает сессии |
Одним взглядом
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesКаждая точка настраивается там, где объявлена; формы, которые может принять обработчик, собраны в Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Объявленную форму и отрисовывает панель: каждая точка показывает свои обработчики, их род и разрешённый порядок. Род — это часть с зубами:
| Род | Когда падает сам обработчик | При отказе |
|---|---|---|
| ворота | шаг отклоняется: недостижимая проверка региона — не пройденная проверка региона | код из каталога платформы плюс человеческая причина. Вызывающие ветвятся по коду; текст причины волен меняться и переводиться |
| наблюдатель | шаг остаётся сделанным, поэтому стартовый набор, который не доехал, стоит сундука, а не входа | отказать он не может |
Чего не вправе сделать ни один обработчик — так это решить, кто вошёл. Ворота отвечают да или нет про личность, которую платформа уже установила; они не называют игрока, не выдают личность и не подменяют подтверждение провайдера. Эта линия и есть разница между переопределяемым входом и пропускаемым.
Модель
Игрок — носитель личности, а не строка в вашей схеме, и его идентификатор стабилен и никогда не переиспользуется, в том числе при слиянии: идентификатор слитого игрока продолжает разрешаться, а не становится висячей ссылкой. Привязка — это тройка: провайдер · внешний субъект · игрок.
| Всегда | Что это |
|---|---|
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 с сохранённым прогрессом.
Profile
Profile — это вид, и платформе принадлежит почти ничего из него. То, что платформа держит об игроке, — это player_id и системный профиль за ним: личности, сессии, привязки провайдеров, всё это в Auth & Players. Всё, чем игрок обладает, — ваша собственная Entity, принадлежащая этому игроку. Profile — это набор таких Entities, который объявляет ваш проект, прочитанный для одного владельца за один проход.
Когда применять
- Экрану нужен срез одного игрока за один вызов — объявленный набор расходится веером по его владеемым 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-sideAttribute-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
[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 — имя этого чтения, а не модуль, стоящий за ним: те же права, те же предикаты, те же фильтры, та же подписка, потому что это та же операция.
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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, а не объявляет свои.
Путь пользователя
От отрисовки лобби до перещёлкивания уровня: серверная запись доезжает до подписанного экрана без того, чтобы экран спрашивал заново.
Social
Одно новое понятие, а всё остальное собрано из того, что у вас уже есть. Отношение двух Actors, с собственным состоянием и инициатором, — это всё, что добавляет этот модуль. Клан, гильдия или отряд — это Group со слоем отношений сверху, а не второй род вещей; а блокировка, нужная нескольким модулям, живёт здесь, чтобы владело ею одно место.
Когда применять
- Игрокам нужны друг друга по имени — друзья, подписки, списки блокировок.
- Клану или гильдии нужна дверь — приглашение от Group'ы, заявка на вступление от Actor и решение по любому из двух.
- Список друзей обязан показывать, кто в сети, — присутствие выводится из сессий, а кому дозволено его видеть, вы объявляете предикатом.
- Другому модулю надо знать, что кто-то заблокирован, — он читает это состояние отсюда, а не держит своё.
- Не нужно, когда вещь — это набор Actors, а не пара с состоянием: это Group, а Group на пару означала бы миллионы Groups по двое, каждая со своим жизненным циклом и правилами входа.
Кто что делает
| Actor | Может | Не может |
|---|---|---|
player | предложить отношение или подписаться; принять, отклонить или отозвать; разорвать взаимное; заблокировать и разблокировать; читать свои отношения и присутствие связанных Actors; подписываться на изменения; подать заявку на вступление | читать чужой список отношений, при каком угодно отношении участников |
moderator | решать по приглашениям и заявкам там, где он держит атом администрирования членства | решать по намерению, разрешения на которое он не держит, — это отвечает forbidden |
Модель
Что несёт Declaration отношения.
| Объявляет | Что это |
|---|---|
kind | symmetric — паре нужно согласие обеих сторон, и машина состояний ниже про него; или 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 — рейт-лимит со временем.
- Частота изменений присутствия в потоке — ограничена частотой обновления, а не отбрасыванием изменений.
- Удержание отклонённых и разорванных отношений — удаление по объявленному периоду.
Путь пользователя
Messaging
Rooms, Groups, игроки: одна модель адресации для чата и уведомлений. Сообщения приходят в разговор; разговоры — это Channels с историей, модерацией и внеполосной доставкой сверху.
Когда применять
- Игроки разговаривают — чат Room'ы, каналы гильдии, личные сообщения — поверх адресации, которая у вас уже есть: Room, Group, игрок.
- Офлайновые игроки всё равно обязаны услышать — шаблонные уведомления с расписанием доставляются внеполосно, пушем.
- Модерация обязана исполняться до доставки — Hook перед отправкой фильтрует или отклоняет, а мьют и блокировка обеспечиваются платформой повсюду.
- Возвращающимся игрокам нужен догон —
History(take: 50)листает разговор при следующем запуске. - Не нужно, если payload — это состояние игры, а не разговор: синхронизируемые поля в Data & Subscriptions и Channels Core это уже разносят.
Кто что делает
| Actor | На этой странице |
|---|---|
player | шлёт и получает сообщения; читает историю; мьютит или блокирует |
moderator | фильтрует, редактирует и банит термины |
backend-service | шлёт или планирует шаблонные уведомления |
Одним взглядом
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)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.
// 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")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.
[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, никогда из сессии игрока:
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, с тем же контрактом, что и везде:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, который получает пуш и читает сбор из истории при следующем запуске.
Catalog & Commerce
Предметы, цены, кошельки, витрины, покупки, entitlements. Настоящие интеграции с магазинами там, где платформы это позволяют (Stripe, App Store, Google Play, Steam, Xbox); витрины с расписанием и адресацией по аудитории; и поток покупки, каждый шаг которого можно перехватить Hook'ом.
Когда применять
- Вы что-то продаёте — за реальные деньги через Stripe, App Store, Google Play, Steam или Xbox либо за валюту кошелька.
- Витрины обязаны вычисляться под каждого игрока — расписание, аудитория и цена считаются на сервере, и никакой математики допуска в клиенте.
- Правилам ценообразования место в одном тестируемом Hook'е — скидки, переоценка и вето исполняются до любого списания.
- Чеки обязаны быть защищены от повтора, а возврат обязан отзывать entitlement через те же Events, которыми пользовалась выдача.
- Не нужно, если вы ничего не продаёте, — хотя награды всё равно приземляются через единственный
Grantкоммерции с происхождениемreward(сундуки за цикл Leaderboards приезжают именно так), поэтому даже игра без магазина сохраняет единый аудируемый журнал выдач.
Кто что делает
| Actor | На этой странице |
|---|---|
player | листает витрины, покупает, управляет кошельком, активирует коды |
seller | настраивает каталог, цены и расписания витрин |
backend-service | проверяет чеки; переоценивает или выдаёт через Hooks покупки |
Одним взглядом
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | это авторский контент, адресуемый ключом, поэтому переименование в коде — это переименование |
kind | consumable — тратится; или 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 | предикат, а не список игроков, — поэтому аудитория это правило, которое продолжает быть истинным, а не снимок |
Витрина, в аудиторию которой игрок не попадает, для этого игрока не существует.
Состояния заказа.
| Из | В |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid или granted | refunded |
| Всегда | Что это |
|---|---|
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.
Inventory
Здесь всё сходится. Выстрелы списывают патроны, дроп сюда приземляется, способности сюда заглядывают, движение этим изменяется — один владеемый набор строк, со стеками, которые инкрементируются, и потолком на владельца, чьё поведение на границе выбираете вы.
Когда применять
- Игроки чем-то владеют, и владение — это строка с владельцем: читается по владельцу, ограничена на владельца, с объявленным, а не подразумеваемым поведением при переполнении.
- Количество накапливается — стек меняется инкрементом с ключом идемпотентности, поэтому повторённое списание не списывает дважды.
- Другие модули тратят из одного набора — выстрелы списывают патроны, дроп выдаёт лут, покупки появляются строками против своего entitlement.
- Не нужно, если число не владеемо: hp, xp и откаты принадлежат Stats.
Кто что делает
| Actor | На этой странице |
|---|---|
player | читает собственные владения и тратит из них |
backend-service | выдаёт, инкрементирует и отзывает от имени игрока, называя игрока, за которого действует |
Одним взглядом
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)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, а не собственные модули.
Leaderboards
Каждая механика, систематизированная. Не каталог типов таблиц. Одна модель, чьи оси складываются во все они: дневные таблицы, борды лучшего круга, суммы гильдий, сезоны, турниры.
Этот блок читается так: кто действует на этой странице (actors), что модуль вам даёт (provides), на каких модулях он стоит (builds-on) и где он висит относительно корня: mounts: root означает playserv.Leaderboards, а не пространство имён под другим модулем (как монтируются модули).
Когда применять
- Счёт обязан ранжировать игроков — дневные таблицы, борды лучшего круга, суммы Groups — одной объявленной моделью, а не системой на каждую таблицу.
- Нужны стандартные чтения — топ-N, вокруг меня, именованный список владельцев — без дополнительного моделирования данных.
- Циклы обязаны закрываться по расписанию, архивироваться (никогда не удаляться) и запускать Hook награды с итоговой таблицей.
- Подозрительные результаты не должны попадать в таблицу — Hook перед отправкой проверяет, срезает или отклоняет с типизированной причиной.
- Турнир — тот же борд с окном заявок, лимитом участников и попытками за цикл.
- Не нужно, если число никогда не сравнивается между игроками: личный счётчик или карьерная сумма — обычные Data & Subscriptions. Модуль упорядочивает результаты; он их никогда не вычисляет и не ведёт турнирную сетку на выбывание.
Кто что делает
| Actor | На этой странице |
|---|---|
player | читает топ-N / вокруг-меня / собственный ранг, подписывается на изменения ранга |
backend-service | отправляет результаты; поправляет или отклоняет их в Hook перед отправкой; выдаёт награды при закрытии цикла |
operator | объявляет борды; закрывает цикл досрочно, поправляет записи (с аудитом), следит за частотой отправок |
Одним взглядом
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 itAttribute-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
[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:
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | handle облачной функции и экземпляр клиента, который SDK выдаёт вам на старте. Один и тот же API, два вызывающих — в Go это ps и psv, и каждый сниппет пользуется тем, что есть у его вызывающего |
| отправитель | функция, которой принадлежит результат матча. В Tanks это Hook on dispose у Room'ы (Rooms), исполняющийся с финальным состоянием на руках |
playerId | платформенный id игрока из Auth & Players, а не имя, которое выбрали вы: Hook читает его из своего payload (e.By.PlayerId в уроке), а хост Room'ы отправляет id того места, которым владеет |
Значения — это поля, названные Declaration: необъявленное поле отклоняется, а не сохраняется.
Чтения, нужные каждой игре, и подписка, которая держит их актуальными:
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("weekly-score", 5) — это окно по рангу, а не страница: пять строк выше вас, пять ниже и своя — одиннадцать строк, симметрично подрезаемых там, где таблица кончается, поэтому ранг 2 получает более короткое окно с обеих сторон, а не сдвинутое. Top листается: он возвращает первые N строк и курсор, а after: идёт по остальным.
ForOwners — то, как работает борд друзей. Платформа не держит графа друзей; вы передаёте владельцев, которые у вашей игры уже есть, — участников Group или список идентификаторов из ваших собственных данных, — и каждая строка возвращается со своим рангом в полной таблице, а не с рангом внутри списка.
OnRankChanged доставляет собственный ранг локального игрока и больше ничего: борд на пятьдесят тысяч участников не проталкивает каждую перетасовку каждому клиенту. Колбэк получает изменившуюся строку — ранг, ранжирующие поля, отображаемые поля, — а Cancel() заканчивает подписку. Сам ранг — это снимок: два чтения с интервалом в секунду могут различаться, пока приземляются отправки, хотя ваша собственная отправка всегда видна вашему же следующему чтению.
Модель
Что объявляет борд.
| Ось | Значения | Как задаётся |
|---|---|---|
| Владелец | игрок · Group | Owner = Owner.Player — борд гильдии это тот же борд с Owner.Group |
| Ключ порядка | одно или несколько объявленных полей, каждое по возрастанию или убыванию | [Rank(1, Sort.Descending)] int Score |
| Агрегация | set · best · increment · decrement | Agg = 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 reset | закрывает цикл, а не удаляет его |
a closed cycle | перестаёт принимать отправки и остаётся читаемым по своей метке — Top("weekly-score", 100, cycle: label), параметр чтения, а не задача выгрузки |
the close event | несёт эту метку, поэтому обработчик читает ровно ту таблицу, которая закрылась, а не пустую, которая только что открылась |
Два Hooks на борде, и род у них разный.
| Hook | Что ему дозволено |
|---|---|
pre-submit | gatekeeper: платформа зовёт его и ждёт. Он может поправить отправленные значения против ваших собственных Entities, срезать их или отклонить с типизированной причиной, а если он падает, отправка отклоняется — fail-closed. Он не вправе поменять владельца записи или её борд: это уже заявлено. Он возвращает вердикт — принять, принять исправленную отправку или отклонить, — и отклонение доезжает до вызывающего типизированной проблемой (Core), той же формы, что любой отказ в SDK |
cycle-closed | observer: срабатывает по факту, вето наложить не может, и сбой там оставляет цикл закрытым |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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: серверные отправки, чтение вокруг-меня, закрытие в понедельник и его награды.
Files & UGC
Файлы приходят порциями и обрабатываются по мере прихода. Загрузки, ассеты и их производные варианты, а также контент игроков с путём модерации.
Когда применять
- Игроки или сервисы загружают блобы — порционные возобновляемые сессии с читаемыми квотами на игрока.
- Обработка обязана начаться до окончания загрузки — читайте файл потоком, порция за порцией.
- Контенту, сделанному игроками, нужен путь модерации —
SubmitUgc, очередь, вердикт, Hooks с обеих сторон. - Одно мастер-изображение обязано обслуживать много платформ — выводите варианты (масштаб, перекодирование) и держите оригинал каноническим.
- Не нужно для маленьких структурированных payload'ов: поле записи Data & Subscriptions их несёт без сессии загрузки.
Кто что делает
| Actor | На этой странице |
|---|---|
player | загружает порции, читает файлы потоком, отправляет UGC |
moderator | просматривает очередь, одобряет или отклоняет отправки |
backend-service | выводит варианты ассетов; вешает Hooks на загрузку и модерацию; задаёт квоты |
Одним взглядом
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clВорота вокруг него — Hooks, с тем же контрактом, что и везде:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
Путь пользователя
Один уровень, построенный игроком, от первой загруженной порции до вердикта об одобрении.
Analytics
Всё, что нужно посчитать потом, а не увидеть сейчас. Объявите типизированное событие телеметрии, испустите его — и оно приземлится рядом с собственными событиями платформы: пройденный уровень, шаг воронки, экономическое событие, длина сессии, отвал в туториале. Этот модуль испускает; он не читает, не агрегирует и сам никуда ничего не отправляет — направление, в котором едет батч, принадлежит роутеру, в Extensibility.
Когда применять
- Нечто обязано быть посчитано потом — шаг воронки, пройденный уровень, экономическое событие, длина сессии.
- Сравнение обязано пережить сборки игры — тип несёт версию схемы, поэтому годовалая воронка не оказывается молча склейкой двух разных смыслов одного поля.
- Объём высок, и потерянная строка допустима, если вы так сказали, — телеметрия единственное место в контракте, где объявленная потеря законна.
- Не нужно, когда кто-то обязан отреагировать: у события телеметрии подписчиков нет вовсе; факт, который другие обязаны услышать, — это игровой Event.
Кто что делает
| Actor | Может | Не может |
|---|---|---|
any actor | объявлять типы в схеме; испускать от своего имени, по одному или батчем; читать объявленные типы | заполнять контекст; читать, запрашивать или агрегировать испущенное |
backend-service | то же самое, а также испускать от имени игрока по делегированию | читать телеметрию — разрешения на чтение нет, потому что нет операции чтения |
Одним взглядом
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))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.
[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, а не модули.
Операторский план
То, чего в 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 & Subscriptions | Entities, миграции, браузер записей, сохранённые виды, импорт/экспорт |
| 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.
Под капотом: транспорт и хаб
Архитектурный справочник, а не поверхность, которую вы зовёте. Ничто на этой странице не появляется в API, против которого вы пишете: нет сокета, который надо открыть, канала, который надо выбрать, конверта, который надо заполнить, и повтора, который надо запланировать. Ваш игровой код никогда не встречается с механикой этой страницы — в этом и смысл. Основные понятия называют стек; механизм живёт только здесь. Он здесь для того, чтобы архитектор мог проверить, что SDK делает с оборванным соединением, выключенным модулем или сообщением, которое обязано прийти ровно один раз.
Стек слоёв
Пять слоёв, сверху вниз: пользовательское пространство, модули, 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 и от других модулей. Сборка, которой модуль не нужен, его не монтирует. Монтирование идёт по пространствам имён, и второй модуль, претендующий на уже занятую точку монтирования, отклоняется в момент монтирования: композиция падает там, а не на первом вызове в него.
Поскольку модули образуют граф, выключение одного имеет последствия ниже по течению, и хаб берёт ровно один из двух путей:
- Выключить зависимую цепочку. Каждый модуль, которому нужен отсутствующий, тоже выключается, и его интерфейсы отсутствуют, а не падают.
- Объявить деградированную функциональность. Зависимые остаются смонтированными и объявляют, чего они больше не могут.
Третьего пути нет. Молчаливая полуработа — смонтированный модуль, тихо роняющий операции, которые он больше не может выполнить, — тот самый способ сломаться, ради предотвращения которого это правило существует, и поэтому выключенная зависимость наблюдаема, а не загадочна.
Почему вы ничего из этого не встретите
Каждое обещание на модульных страницах держится выше этой линии: изменение Entity и есть сетевая операция, Hook — типизированная функция, вход — один вызов. Имена стека до вас доехать могут — Основные понятия указывают сюда, — но обещание в том, что вы никогда ничего из этого не зовёте, а не в том, что слова секретны. Слои ниже существуют, чтобы эти обещания пережили смену транспорта, и страница, которую вам никогда не приходится читать, — мера того, что это работает.
Что вы встретите — контекст доставки, на котором исполняются ваши обработчики, когда заканчивается handle и in-memory реализацию, против которой вы тестируете, — это страницей выше: Потоки, время жизни и тестирование.
PlayServ SDK
Ігровий бекенд, який постачається разом із геймплеєм. PlayServ — це backend-as-a-service для живих ігор: студія веде бекенд своєї гри — дані, гравці, Rooms, Matchmaking, комерція — не хостячи його. SDK — це те, як ваш код, на сервері й у рушії, працює з цією платформою.
Ця сторінка — короткий список того, що тут справді інакше. Усе нижче вирішують один раз на проєкт і налаштовують, а не пишуть; те, що ви викликаєте, живе на сторінках модулів, і кожна секція тут закінчується, називаючи ту, якій воно належить.
Симуляція — не ваш код
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 — найкоротший шлях усередину.
З чого почати
Ігрова арена (карта, танки, стрільба, дроп), оголошена від краю до краю. Ніщо нижче не є ігровим циклом: симуляція виконується всередині платформи, і це весь код, який тут є.
Перш ніж почати. Проєкт із середовищем dev (створений на операторському плані, якому належить цей життєвий цикл), CLI playserv, що в нього ввійшов, і пакет SDK для вашої прив'язки — більше у вашу гру нічого не ставлять.
Шлях користувача
Кожен виклик, який робите ви, — це один із прикладів нижче; кроки між ними — це платформа, що діє за сказаним у Declaration. Здібність, стата і таблиця дропу на рисунку — це пресети entity: Declarations на entities, а не власні модулі.
1. Оголосіть світ
Entities — це ваша схема плюс їхні живі аспекти. Один атрибут на поведінку, поруч із полем, яке він описує:
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)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
[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, StatEvent | payload'ів Hooks, які передає модуль, куди ви чіпляєтеся |
Seat | Matchmaking |
Scope, області синхронізації | Visibility |
Tick, частоти Tick'а | Rooms |
Переліки закриті. Правило, якого не покриває жоден член, пишуть предикатом, а не новим членом: [Aspect("loadout", Visible = "owner == caller.player")] — це те, як виражають видимість на кожне поле, коли Scope.Owner не зовсім те правило, яке ви мали на увазі (Data).
2. Оголосіть Room'у
Шаблон Room'и каже, чим є сесія, і називає Declarations, на які він спирається. Немає ані класу Room, який треба успадкувати, ані методу Tick, який треба заповнити, бо нутрощі Room'и належать платформі:
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)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
[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 — це хмарні функції, які платформа викликає на іменованих кроках. Типізовані на вході й на виході — жодних мішків контексту, жодних логерів у сигнатурі:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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-режимі (мозок бота, навантажувальний тест, операційний інструмент):
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)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.
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).
Куди далі
- Приклади, секція одразу після цієї: Leaderboard у Tanks, аптечки в Tanks або рецепт щоденного турніру для мета-петлі — по одній справжній фічі кожен, і кожен крок веде на сторінку модуля, якому належить щойно вжите.
- Як працює SDK, коли його форма починає важити більше за наступну фічу: Основні поняття — це словник, а чотири статті відповідають на хто викликає (Авторитетність), як пишуть грант (Access & Roles), з чого зроблений SDK (Як влаштований SDK) і як він виконується (Потоки, час життя і тестування).
- Далі модулі. Кожна сторінка модуля має ту саму анатомію — теза, 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 |
| Розробник виділеного сервера Unreal | Rooms (Хостинг Room'и) → Bots → Locomotion · World Objects → Map → Що переживає втрату хоста |
Leaderboard у Tanks
У Tanks, зразковій арені з Getting Started, немає Leaderboard. Цей урок, який ви можете взяти будь-коли після Getting Started, додає тижневу таблицю вбивств за три кроки: оголосити таблицю, відправляти з Hook'а вбивства, читати її в клієнті. Кожен крок веде на сторінку модуля, якому належить щойно вжите, тож урок вчить, показуючи, а не переказуючи.
Крок 1 — оголосіть таблицю
Таблиця — це Declaration: яке поле її ранжує, як складаються повторні відправки, коли вона скидається і хто має право відправляти. Aggregation.Increment додає кожну відправку до поточної суми, тож одне вбивство — це одне очко. Submit.ServerOnly — типове значення, і воно закриває таблицю для клієнтів, а саме це й робить крок 2 єдиним шляхом усередину.
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)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
[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 — це хмарна функція, типізована на вході й на виході, тож відправка вбивства всередині неї — це один рядок.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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: верх таблиці й вікно навколо локального гравця — п'ять рядків вище, п'ять нижче, плюс власний. Обидва повертаються ранжованими записами з убивствами й іменем показу, готовими до прив'язки до списку. Підписка тримає панель свіжою, поки триває матч, і доставляє лише ранг локального гравця.
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))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.
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, яку цей урок розширює.
- Основні поняття — словник, який припускає кожна сторінка модуля.
Аптечки в Tanks
Це другий урок на Tanks. Він робиться у три кроки й без жодного нового модуля: Declaration для ящика, Declaration для того, де ящики з'являються, і один Hook для того, що робить підбирання. Беріть його після Getting Started, у будь-якому порядку з уроком про Leaderboard.
Крок 1 — оголосіть ящик
Ящик — це Entity з двома застосованими пресетами і тілом, яке повідомляє про контакт, нікого не зупиняючи. Response.Pass на шарі pickups — це те, що робить його підбиранкою, а не перешкодою: про контакт повідомляють, рух проходить наскрізь.
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)])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
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, і саме цей крок вирішує, чи відчувається фіча чесною. Розрідження не дає ящикам збиватися в купу, відстань від гравців не дає їм спавнитися просто в дуель, а правило без повторів не дає тому самому місцю бути відповіддю щоразу.
[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)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
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, і це єдиний код в уроці. Він виконується на платформі як хмарна функція, і саме тому він не з'являється на жодній вкладці рушія.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
Щоденний турнір
Що ви отримаєте: щоденний турнір із вікном вступу, засіяними Rooms і виплатою призів — цілком побудований із Declarations і Hooks на модулях, сторінки яких у вас уже є. Нічого нового тут немає; це Leaderboards, Matchmaking, Rooms, Commerce і Messaging, скомпоновані для однієї мета-петлі.
Крок 1 — оголосіть таблицю з вікном вступу і лімітами спроб
Турнір — це звичайне Declaration leaderboard плюс обмеження участі: вікно вступу, стеля учасників і спроб на цикл. У підрахунку очок не змінюється нічого — ключ порядку, агрегація і скидання лишаються рівно такими ж, як на будь-якій таблиці.
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)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
[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 засіває матч — той самий шлях розміщення й місця, яким користується кожен матч, лише обмежений чергою турніру.
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)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.
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) — останнє, що виконується з фінальним станом матчу в руках, і відправляє він саме звідти.
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 у шаблоні.
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 лишаються як є.
Куди далі
- Leaderboards — турнірні осі (вікно вступу, максимум учасників, спроби).
- Matchmaking → Rooms — паті, розміщення і засівання.
- Extensibility → Commerce → Messaging — ланцюг Hooks, що виплачує.
- Основні поняття — словник, який припускає кожна сторінка модуля.
Основні поняття
Слова, якими решта цих сторінок користується, не зупиняючись, щоб їх пояснити. Сторінка модуля припускає, що ви вже знаєте, що таке Actor, аспект чи Room, — тут кожне з них отримує однорядкове визначення й посилання на сторінку, де насправді живе механізм, що за ним стоїть. Прочитайте це один раз перед довідником модулів або поверніться, коли виявиться, що слово несе більше ваги, ніж ви очікували.
Три речі завеликі для запису і мають по сторінці: Авторитетність — хто робить виклик і що саме це вирішує; Як влаштований SDK — з чого SDK зроблений; Успадкування і композиція — як модулі стоять один на одному. У цьому порядку вони читаються як один аргумент.
Чотири поверхні
Кожен модуль виставляє рівно чотири речі, і кожна сторінка модуля організована навколо них. Це і є модель програмування:
| Поверхня | Значення |
|---|---|
| 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.
Авторитетність
Авторитетність — це абстракція, а не дві збірки 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.
Access & Roles
Ролі складають, а не зашивають у код. Атомарні дозволи складаються в ролі; ролі закривають дані аж до рядка й колонки і вирішують, які інтерфейси модулів збірка взагалі бачить. Це заміна поділу ключів на клієнтські й серверні: облікові дані називають особу, а їхні ролі розв'язуються на кожен запит.
Коли застосовувати
- Вам потрібні облікові дані вужчі за «клієнт» чи «сервер» — за ними на кожен запит розв'язуються складені ролі.
- Доступ до даних має зупинятися на рядках і колонках: обмеження за регіоном, маски PII, підрядники лише на читання.
- Збірка має бачити лише ті інтерфейси, які відмикає її роль, — kick/close для відвідувача просто немає.
- Ваш UI має чесно гасити кнопки —
CanIобчислює ту саму політику, яку сервер потім і застосує. - Не потрібно, коли пресети, що постачаються (
player,room-owner,seller, …), уже збігаються з вашими Actors — кожен модуль дотримується їх типово; повний каталог живе в Основних поняттях.
Хто що робить
| Actor | На цій сторінці |
|---|---|
operator | оголошує ролі й політики, задає ліміти по рядках/колонках, видає ролі, випускає ключі |
match-organizer | турнірний персонал із потоку нижче: тримає складений ключ, закриває входи, не може повертати гроші |
every actor | перевіряє CanI перед дією; бачить лише свої відімкнені інтерфейси |
Одним поглядом
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()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 |
Хто роздає роль. Видача й відкликання ролі гравцеві, а також оголошена проєктом типова роль для нового — це операції в Auth & Players: особи належать тому модулеві, а роль розв'язується за особою в облікових даних. Цій сторінці належить те, чим роль є; тій — те, як її передають.
Помилки
- Те, що ховає предикат, відповідає
not found, а неforbidden— інакше сама відмова каже викликачеві, що річ існує, а саме заради цього її й ховали. - Право, якого викликач не тримає, відповідає
forbiddenтам, де існування суб'єкта не є таємницею, і воно називає, чого бракувало, а не відмовляє без пояснень. - Поле поза маскою відсутнє у відповіді, а не присутнє і порожнє: порожнє значення і замасковане не відрізнити одне від одного.
- Роль, що включає себе, напряму чи ланцюгом, — це помилка конфігурації: її відхиляють як Declaration, а не розв'язують у рантаймі.
- Делегування ніколи не розширює спроможності: виклик, якого Actor не міг зробити від себе, відхиляють і тоді, коли він робить його від імені гравця.
Обмеження
Кожна стеля називає свою поведінку на краю; числа надійдуть із розділом про обмеження платформи.
- Розмір вибірки під предикатом рядків обмежений, і модель ACL оголошує цю межу, а не виявляє її. Читання понад стелю отримує стільки рядків, скільки дозволяє стеля, і маркер, який каже, що його обрізали, а ніколи не мовчазну коротку сторінку.
- Межа застарілості розв'язаного дозволу оголошена, і відкликання її не вичікує — воно робить його недійсним одразу.
Шлях користувача
Один ключ організатора турніру, від складання ролі до живої зміни дозволу.
Як влаштований SDK
Два питання плутають одне з одним, і обидва мають короткі відповіді. Як написаний SDK — чому одна й та сама ідея виглядає трохи інакше в Python і в Unreal C++. Як SDK виконується — що стоїть між вашим викликом і дротом. Ця сторінка відповідає на обидва один раз, щоб цього не довелося робити жодній сторінці модуля.
Написаний від загального до окремого
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.
- Модулі компонуються, а не успадковуються. Як саме і що тут чесно означає «успадкування» — це Успадкування і композиція.
Потоки, час життя і тестування
Цикл ваш. Ми доставляємо рівно в одне місце і ніколи за вашою спиною. SDK не запускає жодного потоку, про який вам треба знати, не дає вам жодного замка і викликає ваш код з одного контексту, який ви обрали на старті. Викликайте нас із будь-якого потоку; ми викликаємо вас із одного.
Один контекст доставки, і цикл ваш
Екземпляр оголошує рівно один контекст доставки — єдине місце, де виконуються всі його обробники. Він фіксується, коли ви робите ініціалізацію, і не змінюється до кінця життя екземпляра. Event, Delta даних, результат виклику — усі вони приходять туди і більше нікуди.
Форм у нього дві, і ви обираєте одну на старті:
- Ви його прокачуєте. Рантайм сам нічого не робить; ви вичерпуєте доставки, що чекають, зі свого власного циклу. Це та форма, якої хоче рушій, — доставки надходять на ігровий потік, у кадрі, який ви обрали.
- Він наш. Рантайм тримає один виділений потік виконання. Це та форма, якої хоче консольний хост чи виділений сервер.
Жодна з них не є запасною для іншої, і третього варіанта з пулом потоків немає. Уся суть обіцянки про один контекст у тому, що вам ніколи не доводиться питати, скільки потоків ми зробили.
Контекст ніколи не є аргументом. Жоден обробник не бере параметра «на якому я потоці», і немає чого запитувати. Де виконується ваш обробник — це властивість контракту, а не дані виклику.
Запуск і зупинка явні
Ініціалізація — це виклик, який робите ви, і він відповідає результатом. Ніщо не ініціалізується ліниво під час першого використання — це заборонено, а не просто небажано, і причина варта речення: лінивий старт переносить єдине місце, де видно вимкнений модуль, у той довільний виклик, який трапився першим, де це читається як збій того виклику.
Вимкнений модуль називають на старті, і результат каже, яка з двох речей сталася: увесь залежний ланцюг вимкнено або ви працюєте з меншим плюс список того, що недоступне. Мовчазного третього випадку немає.
// 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, idempotentAttribute-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.
// 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 у нас активно хибна. Вимкнення явне, повне та ідемпотентне — після його успіху жоден обробник цього екземпляра більше не викликається, — але про роботу, яка вже в польоті, воно не каже нічого. Операція, яку ви почали до вимкнення, лишається доступною для виявлення тими засобами, які ця операція назвала. Якщо вам треба знати, чи пройшла покупка, вимкнення — не спосіб це з'ясувати.
У кожного хендла один оголошений кінець — і це ніколи не збирач сміття
Підписка, дескриптор відкладеної роботи, сесія: кожне з них — хендл, і в кожного рівно один кінець, який називає контракт. Звільнення ідемпотентне, тож звільнити двічі — не помилка.
Три наслідки, у яких легко помилитися:
- Кінець ніколи не є фіналізатором, деструктором чи областю видимості. Хендл, який ви покинули, лишається відкритим. Це баг у вашому коді, а не щось, що ми тихо забираємо назад, бо час життя, залежний від мови, був би іншим часом життя в кожній прив'язці.
- Використання хендла після його кінця — це оголошена відмова, з кодом. Не порожній результат, не невизначена поведінка і не загальна помилка про звільнений об'єкт, яка не несе нічого, з чим можна щось зробити.
- Обірваний зв'язок не є кінцем хендла. Підписка переживає від'єднання і далі отримує після перепідключення. Хендли закінчуються з причин, які називає контракт, і втрата мережі не належить до них.
Жоден хендл не переживає екземпляра, що його видав: щойно ви виконуєте вимкнення, кожен хендл, який він вам дав, — у своєму кінці.
Викликати з обробника можна; чекати всередині нього — ні
Викликайте поверхню з будь-якого зі своїх потоків. Кожен хендл вільний щодо потоків, і це обіцянка, а не властивість сьогоднішньої збірки. Ви ніколи не візьмете нашого замка, не чекатимете на нашому бар'єрі, і вам ніколи не скажуть викликати щось «під замком» — жоден примітив синхронізації взагалі не є частиною поверхні.
Обробники одного екземпляра серіалізовані: два ніколи не виконуються одночасно, а порядок усередині одного потоку зберігається. Тож обробникові не потрібно власного блокування.
Серіалізовано не означає дедупліковано. Порядок — це одна обіцянка; скільки разів доставлять повідомлення — інша, оголошена на типі повідомлення. За «щонайменше один раз» ви побачите те саме повідомлення двічі, а ключ дедуплікації, який завжди йде разом із ним, — це те, чим ви це відрізните.
Почати операцію зсередини обробника законно і не може призвести до дедлоку. Але її результат ніколи не приходить усередину того самого обробника — він повертається окремою доставкою на тому самому контексті. Іти всередину можна; розвертатися всередині — ні.
Блокувати контекст доставки заборонено, і ця заборона не є порадою. Чекати на мережі, чекати на чужому замку, синхронно чекати на власний виклик — усе заборонено всередині обробника. Заборона має симптом: обробник, що тримає контекст понад свій оголошений бюджет, дає або оголошену деградацію доставки, або оголошену відмову. Чого він не дає ніколи — це мовчазного сповільнення, яке ви виявите в сесії гравця.
// 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 downAttribute-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.
// 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, тож ніщо не з'являється і не зникає деінде через те, що ви встановили поруч.
Якщо на необов'язкову одиницю є посилання, а завантажитися вона не може, це оголошений результат ініціалізації, у тому самому місці, де повідомляють про вимкнений модуль. Ніколи не заглушка, що тихо нічого не робить.
Кожна прив'язка оголошує мінімальну версію рантайму, під яку її зібрано. Нижче за неї ви дістаєте відмову на ініціалізації, а не часткову роботу: застарий рантайм інакше ламається на першій спроможності, якої йому бракує, а це десь довільно у вашому коді і зазвичай на машині гравця, а не на вашій. Підняття цього мінімуму є ламкою зміною і проходить той самий процес, що й будь-яка інша.
Куди далі
- Getting Started — перша Room від краю до краю.
- Як влаштований SDK — чому поверхня одна і як складаються шматки.
- Під капотом — шар під цим, якщо вам цікаво.
Core
Core — це єдиний об'єкт, який ви створюєте, і все інше висить на ньому. Один ключ на вході — і у вас є контекст, особа, типізовані відмови, трасування, батчинг. Кожен виклик модуля проходить крізь нього, і жоден модуль не постачає власної версії.
Коли застосовувати
- Вам треба знати, хто ви і де ви, — особа, ролі, розблоковані модулі, project · env · region, усе на одному об'єкті, який ви тримаєте.
- Хмарна функція мусить писати як гравець — запис атрибутується тому гравцеві, а зафіксовано обидві сторони: і функцію, і гравця.
- Повтори ніколи не повинні застосовуватися двічі — пакетні операції несуть ключ ідемпотентності.
- Відмова має бути такою, щоб на ній можна було розгалузитися і щоб її можна було знайти пошуком, — кожне кидання — це типізований
Problemзі стабільним кодом. - Не потрібно, коли вам треба повідомлення, виклики чи стан — це Primitives: Events, RPC, Data.
Хто що робить
| Actor | На цій сторінці |
|---|---|
any actor | читає особу, контекст, ролі через Whoami |
backend-service | діє як гравець; батчить ідемпотентні операції |
operator | читає трасування для викликів, що впали або були повторені |
Одним поглядом
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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 | вона відповіла відмовою, несучи код із каталогу платформи |
local | SDK відмовив до відправлення, з власного опублікованого словника |
unknown | виклик було надіслано, а відповідь не прийшла. Ані «платформа сказала ні», ані «ми так і не спитали» |
Що правдиве для кожної відмови.
| Завжди | Що це |
|---|---|
a refused operation applied nothing | атомарність — обов'язок платформи, а не ваш: жодного компенсаційного читання на звичайній гілці помилки. Винятків рівно два, і про обидва сказано там, де вони виникають, — таймаут, результат якого невідомий, і батч із поелементною семантикою |
the delivery path | не змінює помилки: той самий код, категорія і походження доходять до вас незалежно від того, чи прив'язка кидає, чи повертає значення результату, чи спрацьовує зворотним викликом на підписці. Шлях, що несе менше за інший, — дефект цієї прив'язки |
a timeout | не є результатом: це третє походження, наведене вище, і що з ним робити, оголошують для кожної операції, а не вгадують |
Core несе контекст, а не повідомлення. Випускати факти і підписуватися на них — це примітив Events; виклики — запит/відповідь, односторонній, розсилка по Group — це примітив RPC; стан, підписки і потокові читання — це примітив Data, адресований через Entity. Аудиторії, на які розсилають усі три, — це четвертий примітив, Groups. Розмірні передачі (вивантаження, завантаження) виходять на поверхню у Files & UGC. Семантика монтування — простори імен, відхилення колізії під час монтування — живе на Під капотом.
Помилки
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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 не виносить на поверхню ані чинного значення, ані залишкового запасу, ані попередження про наближення, і нічого про ліміт ніколи не ставлять перед гравцем. Відмова несе все, що є:
- категорію, а вона й каже, чи має повтор узагалі сенс
- чий це був ліміт
- коли дозволено повтор і за яке вікно
Розгалужуйтеся на цьому. Лічильника для опитування немає, і бюджету для показу немає.
Шлях користувача
Один виклик, що падає, від кидання до трасування, яке читає оператор.
Events
Event — це факт, що щось сталося, доставлений усім, хто має це почути. Беріть його для того, що стається один раз і чого не можна надолужити з поточного значення: постріл, покупка, вхід у Room'у.
Коли застосовувати
- Щось сталося, і інші мають зреагувати — постріл, замкнені двері, завершений матч.
- Аудиторія різна — той самий випуск доходить до загону, до Room'и чи до одного Actor'а, і вирішує це ціль, яку оголошує тип.
- Вам потрібні типізовані обробники з автодоповненням — оголошений Event стає
send.іon.на своїй поверхні, кожен зі своїм контрактом. - Факт має лишатися доступним для читання і за годину — оголосіть тип утримуваним і читайте його назад за періодом.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує Events через [Event], відправляє схему |
any actor | випускає через send., підписується через on. |
Одним поглядом
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))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, ніколи адресою, переданою на випуску |
clock | sim_time або timestamp, ніколи обидва: sim_time для фактів усередині симуляції, які беруть участь у передбаченні, компенсації лагу і відкоті; timestamp для фактів поза нею, як-от покупка чи вхід |
retention | transient — доходить до тих, хто підписаний на мить випуску, і не зберігається; або retained — зберігається і читається назад за типом і періодом, а не через поверхню запитів, яку несе Data. Оголошується, ніколи не виводиться з роду події |
term | на типі retained: скільки його тримають і що стається на спливі. «Назавжди» не входить до значень |
delivery | щонайбільше один раз, щонайменше один раз або рівно один раз — оголошується на типі, тож підписникові ніколи не треба питати, який з них ужив випуск; «рівно один раз» називає межі, всередині яких воно тримається |
context | контекст, у якому оголошено тип, глобальний чи локальний. Глобально оголошене ім'я видно в локальних контекстах; локально оголошене не видно вище. Те, що модуль випускає, є його контрактом у будь-якому разі — про неоголошений Event викликач ніколи не дізнається |
Що несе випуск.
| Поле | Що це |
|---|---|
type | оголошений Event. Два випуски ніколи не зливаються: два постріли — це два Events, і другий не поглинає першого, а саме це відділяє Event від поля [Sync], яке несе Data |
payload | відповідний схемі типу |
source | Actor, що випустив, плюс його екземпляр, коли випустила 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, а не рішення, ухвалене на випуску, тож той самий тип завжди тримають однаково, і жодному викликачеві не треба пам'ятати, який виклик був який.
| Transient | Retained | |
|---|---|---|
| Доходить до | тих, хто підписаний тієї миті | до них, а також до підписника, що прийшов пізніше |
| Після цього | зникає | тримається оголошений термін |
| Читається назад | ні | так, протягом терміну |
| Після терміну | — | вибірка відмовляє, а не відповідає порожнім |
[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)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 до маркера, який кожен учасник загону бачить на власному екрані.
RPC
Типізований виклик, чиє тіло живе десь-інде. RPC — це другий примітив: оголосіть процедуру там, де їй належить — на модулі або всередині Entity, — і кожна прив'язка дістане згенерований метод, на який можна чекати. Дієслово — invoke: односторонність — це режим, що його називає Declaration, а не друге дієслово, і do не існує.
Коли застосовувати
- Викликачеві потрібна відповідь — запит/відповідь із типізованим поверненням.
- Викликач звітує і йде далі — оголошений односторонній RPC, назад нічого не передається.
- Робота переживає виклик — оголошений відкладений RPC віддає дескриптор роботи замість таймауту.
- Одне питання, багато відповідачів — виклик по Group — це N викликів, і кожна відповідь надходить прив'язаною до учасника, який її надіслав.
- Дієслово належить речі — оголосіть його всередині Entity; RPC однієї Entity не живе більше ніде (Entity показує Declaration).
- Не потрібно, коли нікого не просять діяти: факт, на який інші лише реагують, — це event.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує RPC, їхні режими і хто має право їх викликати |
any actor | робить виклик із відповіддю або односторонній там, де Declaration це дозволяє |
group member | відповідає на виклик, що розходиться на всіх; назад передається одна відповідь на учасника |
Одним поглядом
[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)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 mode | with a reply — значення оголошеного вихідного типу або типізована відмова; або one-way — без відповіді, і викликач дізнається лише про локальний збій відправлення. Односторонній не можна використовувати там, де викликачеві потрібен результат: невідомий результат коштує більше за відому відмову |
execution mode | immediate — результат повертається всередині виклику; або deferred — виклик повертає дескриптор роботи, а результат читають або він надходить підпискою. Оголошується, ніколи не обирається реалізацією за навантаженням, бо викликач будує свою поведінку на формі відповіді |
streaming | чи приходять вхід і вихід частинами і чи обробляють їх у міру приходу, а не цілком |
idempotency | односторонній RPC теж несе ключ ідемпотентності: відсутність відповіді не означає відсутності передоставки |
overridability | оголошується на самому методі. Відсутність Declaration означає, що метод не можна перевизначити: типово перевизначуваних не буває |
context | де його оголошено. RPC, оголошений усередині Entity, є частиною цієї Entity і поза нею не існує. Оголошення його в ігровому сервері і є реєстрацією в маршрутизаторі — другого способу додати його немає |
Що несе виклик.
| Несе | Що це |
|---|---|
arguments | лише те, що викликач мусить обрати |
implicit context | отримувач, викликач і навколишній контекст, зв'язані ще до вашого першого написаного параметра — у методу Entity ніколи не просять ідентифікатора цієї Entity |
references | аргумент, який є об'єктом SDK, передається типізованим Ref — ідентифікатором чи курсором, ніколи копією свого вмісту. Отримувач розв'язує його від свого імені, під тими самими дозволами й предикатами: посилання — це адреса, а не виданий дозвіл |
outcome | значення оголошеного вихідного типу або типізований Problem |
Що тримає дескриптор відкладеного виклику.
| Тримає | Що це |
|---|---|
state | accepted → 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.
[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 ranexport 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 ranAttribute-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'а — новий відхиляють, а ті, що в польоті, добігають.
- Час життя дескриптора — після нього результат недоступний, і це відмова.
- Глибина ланцюга викликів — оголошена відмова у разі перевищення, ніколи не вичерпані ресурси й не мовчазний обрив.
Шлях користувача
Один надісланий рахунок, один пінг, про який відзвітували, один загін, у якого спитали, чи він готовий.
Data
Ви змінюєте одне поле. Усе, що далі по ланцюжку, стається без жодного рядка коду. Data — це третій примітив: механіка під кожним синхронізованим полем — Deltas відносно останнього підтвердженого стану, аспект як одиниця політики, пріоритет і частота надсилання, відновлювані підписки, утримуване вікно і Hooks до і після зміни.
Ви звертаєтеся до entities, а не до таблиць — поверхню читання і зміни (знайти, відфільтрувати, відсортувати, посторінкувати, підписатися на вибірку) див. в Entity; ця сторінка — про механіку під нею. Шляху споживача до таблиці немає, як немає й другого способу писати: зміна — це операція Entity, а Delta — те, що з неї випливає.
Коли застосовувати
- Вам потрібно, щоб стан реплікувався клієнтам без коду знімків — зміна поля і є всією синхронізацією.
- Поля різняться терміновістю чи аудиторією — пріоритет і стеля частоти надсилання на аспект, плюс предикат видимості для туману війни.
- Клієнт, що перепідключається, не має розходитися мовчки — проміжок виявляють і називають, а на проміжок поза утримуваним вікном відповідають повним станом.
- Вам потрібне недавнє минуле — утримуване вікно Deltas, індексоване за
sim_time, і є тим, що читають передбачення й компенсація лагу. - Правило валідації має жити в одному місці — Hook до зміни затискає або накладає вето до того, як зміна застосується.
- Регулятори не потрібні, коли вам треба лише прочитати чи запитати — поверхня Entity спирається на цю механіку, не торкаючись її.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує аспекти, їхню політику синхронізації і предикат видимості |
any actor | підписується на ціль; відновлюється з позиції; просить повний стан |
backend-service | Hooks до і після зміни |
operator | читає вартість пакета на кожного Actor'а; бачить, коли доставка деградує або пакет обрізають |
Одним поглядом
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 mode | shared packet — те саме всім, дешево для CPU; або per-actor packet — кожному своє за його зоною видимості, дорого для CPU і необхідно за великого населення |
visibility rule | предикат, що вирішує, хто взагалі отримує, — Visibility проєктує цю половину повністю |
Що тримає підписка.
| Тримає | Що це |
|---|---|
target | екземпляр, вибірку або аспект, і вона отримує Deltas цієї цілі. Ціль — не потік: одна ціль може покривати багато пар, а порядок обіцяють усередині пари, а не в межах усієї цілі |
position | звідки вона відновлюється: її пред'являє споживач. Якщо проміжок більший за утримуване вікно, замість потоку Deltas надходить повний стан, тож довге від'єднання ніколи не лишає клієнта мовчки хибним |
state | active → gap detected → resynchronised | closed, і closed термінальний |
Що правдиве для кожного потоку.
| Завжди | Що це |
|---|---|
merging | Deltas його допускають: 100 → 90 → 80 між надсиланнями може надійти як 100 → 80, бо кінцевий стан усе одно правильний. Саме це відділяє Delta від Event'а, де втрата одного втрачає інформацію назавжди |
gap detection | мовчки втратити Delta заборонено; номер послідовності в парі — це те, що рахує споживач |
ordering | тримається всередині однієї пари екземпляр × аспект; між парами його не обіцяють у жодній формі |
traversal | лише по оголошеному: тим, що може бути фільтром, сортуванням чи включенням, є оголошене поле і оголошене посилання. Поверхня вибірки entity — це проєкція тієї моделі, а цей примітив не дає споживачеві власного обходу: другої мови запитів немає |
history | будується з Deltas: миттєве вікно Entity — це утримуване вікно Deltas, індексоване за sim_time. Його глибина — ліміт цього примітива, і він не обіцяє відтворюваності для полів з рухомою комою |
the packet budget | деградує як оголошено: коли бюджет на кожного Actor'а вичерпується, платформа відкочується до спільного пакета як оголошено, а не починає губити отримувачів навмання |
Що Hook може робити і коли.
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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'а — на вичерпанні оголошена деградація до спільного пакета.
Шлях користувача
Одна зміна позиції, від присвоєння до виправленого руху на кожному екрані.
Groups
Один список, один слухач на всіх. Group — це четвертий примітив: іменований набір Actors, який отримує як одне ціле. Ви звертаєтеся до Group — і чує кожен учасник. Room, чат, пул Matchmaking і список розсилки — це той самий примітив із різними правилами: інша логіка входу і виходу, інший час життя, той самий список під ними.
Коли застосовувати
- Вам потрібні паті, загони чи гільдії — іменовані набори гравців з оголошеною місткістю і, де тип його оголошує, часом життя.
- Членство має визначатися оголошеним правилом, яке обчислює платформа, — нові ветерани потрапляють туди без cron-задачі й без вашого власного виклику переобчислення.
- Ви хочете звернутися до багатьох гравців одразу: оголошений Event розходиться через
send.*, оголошений RPC доходить до кожного учасника, і кожна відповідь повертається іменованою. - Вам потрібна одна модель членства, перевикористана як аудиторія, — область visibility, розмова messaging, паті matchmaking.
- Створювати не потрібно, коли набір — це учасники однієї сесії: rooms — це той самий примітив із правилами Room'и, і він уже до них звертається.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | створює Groups з оголошених типів, входить і виходить, додає чи вилучає учасників, надсилає Events, викликає RPC на всіх учасників; тримаючи право адміністрування членства Group, вилучає учасників і закриває її |
room-owner | правила місць у Room'і спираються на цей примітив (налаштовуються в Rooms) |
backend-service | оголошує типи Groups та їхні правила; Hooks на вході й виході |
Одним поглядом
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 groupAttribute-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'и ніколи не можуть розійтися.
[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: ...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() };
[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 mode | explicit — учасника додають і вилучають дією; або 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. Group — це один слухач на всіх |
states | created → 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 на всіх учасників приносить відповідь від кожного, і загін стає в чергу як одне ціле.
Extensibility
Кожен сценарій платформи — це ланцюг зареєстрованих функцій. Замініть ланку або огорніть її. Саме це конкретно означає «платформа, яку можна налаштовувати», і саме це заміняє відкриті вихідники: ви заміняєте власні кроки платформи своїми, тож наші вихідники вам не потрібні.
Коли застосовувати
- Крок платформи має виконати вашу логіку — оголосіть заміну іменованої ланки через
[Override(…)]. - Вам потрібні перевірки чи побічні ефекти навколо кроку — упорядковані
Before/Afterпроміжні шари, що можуть накласти вето або сповістити. - Код має виконуватися за розкладом, на Event чи на вебхук — тригери передають вам розібраний, типізований контекст.
- Ви маєте знати, що саме виконуватиметься, до деплою — проженіть ланцюг «насухо» і прочитайте розв'язаний порядок.
- Не потрібно, коли правило стосується запису в одну Entity: Hook у Data — легша форма.
Хто що робить
| Actor | На цій сторінці |
|---|---|
backend-service | перевизначає ланки, огортає кроки проміжними шарами, пише обробники тригерів |
operator | оглядає ланцюги, задає порядок, читає секрети, проганяє розв'язання насухо |
Одним поглядом
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Модель
Що платформа дає вам, щоб до цього чіплятися.
| Термін | Що це |
|---|---|
registered function | один перевизначуваний крок платформи — «створити профіль», «розв'язати ціну» |
scenario | упорядкований ланцюг, який виконує потік платформи: автентифікація, вхід, покупка |
overridability | чи можна ланку замінити, лише огорнути, чи вона фіксована |
middleware | упорядкований обробник до/після навколо ланки |
trigger | те, що запускає ваш код: Event, розклад, вебхук |
secret | значення, яке ваш обробник має право прочитати |
invocation | один запуск, зі своїм трасуванням |
Що оголошує Hook.
| Оголошує | Що це |
|---|---|
position | іменований крок, до якого він чіпляється |
kind | gatekeeper — перевірка допуску чи валідація, і він у разі збою відмовляє, тож крок не виконується, коли ламається сам Hook; або observer — лог, сповіщення, лічильник, і він у разі збою пропускає, крок виконується, а про збій усе одно звітують, а не замовчують його. Типового значення немає |
moment | before — перед валідацією, отримує типізований 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 sees | Events, які сценарій випускає після цього, бо перевизначення чи проміжний шар виконується на платформі, а рантайм рушія не місце, щоб його хостити. Саме це мають на увазі вкладки @na у прикладах цієї сторінки, коли підписуються на події, що з цього виходять |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Візьміть, змініть, відправте. «Пропрієтарний 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 малює ту саму покупку зі свого боку.
Ланцюг із перевизначеннями і проміжними шарами можна розв'язати і прочитати до того, як щось запуститься. Розв'язаний порядок можна оглянути в панелі і з коду.
Уроки й рецепти: Leaderboard у Tanks уживає Hooks цього модуля; щоденний турнір проходить крізь цей модуль.
Schema as Code
Оголосіть модель у коді, відправте її, отримайте типи назад. Шлях розробника всередину схеми: адмінська панель і код пишуть ту саму модель, а кодогенерація замикає петлю для кожного рушія.
Коли застосовувати
- Ваша модель даних має жити в коді й проходити рев'ю як код — оголосити,
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: відправляє на мерджі й перегенеровує типи рушія після цього |
Одним поглядом
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 = 0Attribute-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
[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 | стабільне ім'я, за яким до нього звертаються. Перейменування в коді — це перейменування, а не видалення і створення |
kind | Entity, part або enum, оголошені в коді |
ownership mode | seed — код створює запис, якщо його немає, а повторний 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 у коді до перегенерованих типів рушія.
Entity
Модуль, на який спирається все інше. Entity — це Declaration схеми, вирощене живими аспектами: дані 0..*, стани 0..*, RPC 0..*, Events 0..*, Hooks та історія змін. Карти прив'язують перешкоди до entities, колізія прив'язує аспект трансформа, стати і є пресетом, а об'єкти світу — пресет плюс машина станів.
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, читає стан |
Одним поглядом
[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()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();
};
[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:
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"))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.
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, цей екземпляр, — тож чують його всі, хто підписаний на двері, і жоден список отримувачів не йде разом із відправленням.
Модель
Танк, двері, смуга характеристики і квест — усе це 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'і |
time | AfterSeconds на стані — це оголошений тригер, а не корутина: він іде за симуляційним годинником Room'и, просувається разом із sim_time, стоїть, поки Room не симулює, а видалення екземпляра завершує його машини і їхні незавершені таймери разом із ним |
Що дозволено вибірці.
| Вісь | Що допустимо |
|---|---|
filter і sort | лише оголошені поля — хендла таблиці немає, а вибірка адресована через Entity й обмежена Room'ою або проєктом |
include | оголошений ref, утягнутий разом зі сторінкою |
paging | непрозорим курсором: не зсувом, не id рядка, і його значення не переживає зміни версії. Передавайте його назад, ніколи не розбирайте |
access | предикати діють до посторінкування, тож на сторінці ніколи немає дірок там, де були б приховані рядки |
live | підписка на вибірку тримає її живою, і члени входять і виходять у міру того, як змінюються їхні дані |
// 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()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.
// 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:
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})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.
// 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 до дзвіночка, який чує гравець.
Успадкування і композиція
Модулі стоять один на одному, і ніщо з цього не є успадкуванням класів. Немає ані базового модуля, від якого походити, ані ієрархії, яку розширювати, — модулі утворюють граф. Ця сторінка про те, що тут чесно означає «успадкування», і про шість механізмів, які роблять роботу натомість.
Що тут означає успадкування
Це слово покриває чотири різні механізми, і їх варто розвести по іменах.
- 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.
Entity presets
Пресет — це іменований набір аспектів entity — дані, стани, RPC, Events, Hooks — упакований під один ігровий випадок. Ви застосовуєте пресет, підкручуєте його числа або виводите свій. Застосування додає аспекти вашому типу; воно не ставить ваш тип під щось — пресет не є модулем, і в нього немає нічого власного, від чого успадковувати. Стати, здібності, снаряди, таблиці дропу й об'єкти світу — це п'ять пресетів, а не п'ять підсистем: те саме Declaration, та сама синхронізація, той самий порядок Hooks.
Коли застосовувати
- Річ у вашій грі несе числа, які затискаються, регенерують і запускають перехід на своїх межах.
- Дії потрібні вартість, кулдаун, фази й ефекти, досяжні з одного клієнтського дієслова.
- Щось вилітає, і його влучання має бути розсуджене чесно для стрільця з лагом.
- Здобич має походити зі зважених шансів, які точно переграються, коли гравець сперечається про дроп.
- На карті є меблі — двері, кнопки, пастки, об'єкти, що руйнуються, — зі станами, що мають пережити вхід посеред раунду.
- Пресети не потрібні, коли Entity — це просто синхронізовані дані. Оголосіть поля і зупиніться.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує стати, здібності, снаряди, таблиці дропу, об'єкти світу |
room-owner | підкручує числа пресетів, кидає таблиці дропу, створює об'єкти світу |
player | застосовує здібності, стріляє, підбирає здобич, взаємодіє з об'єктами |
Одним поглядом
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})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.
// 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);Модель
Пресет не вводить нових понять. Усе, що він додає, виражається засобами, які 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, — і жоден із них не є модулем, який ви монтуєте.
Rooms
Room — це ігрова сесія; платформі байдуже, що її хостить. Одна абстракція покриває виділений сервер на матч, одну велику спільну карту, розрізану на логічні шари, Room під master-client і міні-гру, що хоститься на бекенді. Нутрощі Room'и наші; ви ведете Room'у ззовні.
Коли застосовувати
- У вашій грі є сесії — матчі, лобі, підземелля, перегони, — і щось має володіти їхнім життєвим циклом, членством і перепідключеннями.
- Ви хостите на виділених серверах, на master-client гравця або на самому бекенді, і гравців треба туди маршрутизувати.
- Одна спільна карта має вести багато логічних сесій — шари, обмежені Visibility.
- Гравці входять посеред сесії і мають побачити поточну правду — стан Room'и на вході, далі живий трафік.
- Обірваний зв'язок не повинен коштувати місця — пільгове вікно шаблону (45 с у
battle) відновлює те саме членство. - Не потрібно, коли фіча — суто запит/відповідь над записами: Data її вже покриває.
Хто що робить
| Actor | На цій сторінці |
|---|---|
room-owner | реєструє Rooms по всьому процесу; на одному екземплярі Room'и — адмінський інтерфейс на екземпляр: править живу конфігурацію, кікає, замикає, розсилає всім, розпускає |
entry-validator | приймає або відхиляє запити на вхід із кодом і причиною |
room-visitor | переглядає, входить із даними, перепідключається у пільговому вікні, виходить |
spectator | входить, ні на що не претендуючи; отримує розсилки і живий трафік |
match-organizer | резервує місця, які зараховуються до місткості; бронь спливає за терміном шаблону (90 с у battle) |
Одним поглядом
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 longAttribute-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
[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: зареєструвати, хостити кілька на процес, правити живу конфігурацію, кікати, публікувати, розпускати.
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
Клієнт:
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))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.
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 mode | our 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 two | API, що охоплює всі Rooms (переглядати, реєструвати, перелічувати); API на одну Room'у, яке викликає будь-який учасник (увійти, вийти); і адмінський інтерфейс на екземпляр — кікнути, замкнути, поправити конфігурацію, закрити, розпустити цю Room'у — відкритий тому, хто тримає адмінську чи хостову роль для того одного екземпляра, а не членству |
Два режими авторитету.
| Режим | Хто веде 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'и.
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()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.
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 reservations | Matchmaking забирає слот на термін броні шаблону — 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, що показує, хто приєднався.
Хто що бачить і яка машина це веде
Два питання, що звучать як одне. Хто що бачить — про клієнта: який зріз стану Room'и доїжджає до якого гравця. Яка машина це веде — про хост: який процес володіє Entity і який володітиме наступним. Слово, що їх змішує, — replication: в ігровому рушії воно зазвичай називає перше питання, а тут — друге.
| Ви маєте на увазі | Читайте |
|---|---|
| який клієнт отримує який стан і скільки його | Visibility, разом із Data та Prediction |
| яка машина володіє Entity і що стається, коли вона вмирає | What Survives Losing a Host |
Вони оголошуються у двох різних місцях
Ні те, ні інше не налаштовується в рантаймі, і спільної Declaration у них немає.
| Оголошується на | Що називає | |
|---|---|---|
| хто що бачить | аспекті — Data, Visibility | предикат видимості, стелю об'єктів і її порядок, які сусідні області видно, і режим доставки |
| яка машина це веде | типі Room'и — Rooms | режим авторитету, наскільки зовнішньому авторитету вірять щодо наслідку, і поведінку при падінні хоста |
Різняться вони й тим, що стається, якщо не сказати нічого. Аспект без власного правила видимості доставляється у спільному пакеті — це типова поведінка, і для маленької Room'и вона правильна. Тип Room'и, що не назвав режим авторитету, відхиляється: типового значення немає, бо обрати між нашою симуляцією та зовнішньою за вас ніхто не може.
Visibility
На 40 гравцях знімок усієї Room'и годиться. На 200 — уже ні. Зона видимості вирішує, хто що отримує, оголошеним предикатом, а не перемикачем, який ви клацаєте на кожному об'єкті. Розсилка і пакети на кожного Actor'а — це два оголошені режими доставки однієї моделі, тож перехід між ними — це налаштування, а не переписування. Це оптимізація каналу, а не дозвіл — про це див. Access.
Одну оголошену модель — предикат, шари, рівні деталізації — читають двома способами. Перехід між ними — налаштування, а не переписування, бо обидва є прочитаннями того самого Declaration.
| Розсилка | Пакети на кожного Actor'а | |
|---|---|---|
| Надсилає | усю Room'у, усім | кожному гравцеві лише той зріз, який обирають його правила |
| Пасує | маленькій Room'і; це типове значення | натовпу, де розмір пакета має лишатися передбачуваним |
| Читає Declaration | один раз, на Room'у | на кожного Actor'а |
Те, що не має протекти, відсутнє в пакеті, а не приховане на клієнті — його ніколи не надсилали, і саме це робить його властивістю безпеки, а не смуги пропускання.
Коли застосовувати
- Ваші Rooms переростають розсилку на всю Room'у — 200 гравцям потрібні потоки околиці на кожного клієнта, а не кожна Delta.
- Стан не має протікати: туман війни й поля лише для власника ніколи не мають надсилатися, а не ховатися на клієнті.
- Кілька сесій ділять одну карту і не мають бачити одна одну — шар є ще одним предикатом.
- Розмір пакета має бути передбачуваним у натовпі — обмежте кількість об'єктів і оголосіть порядок, щоб «найближчі N» були обіцянкою, а не випадковістю щільності.
- Гравець на межі має бачити за неї — оголосіть, які сусідні області видимі, бо типово це лише його власна, а межа інакше читається як стіна порожнечі.
- Не потрібно, коли Room маленька: режим доставки спільним пакетом її вже покриває.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує предикат видимості, стелю об'єктів і її порядок, які сусідні області видимі, і режим доставки |
any | підписується і отримує те, що впускає зона; може знизити стелю об'єктів для себе в оголошених межах |
Одним поглядом
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 scopeAttribute-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
[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 mode | shared packet — те саме всім, дешево по CPU; або per-actor packet — кожному своє за його зоною, дорого по CPU і необхідно на великих населеннях |
Що правдиве для кожної зони.
| Завжди | Що це |
|---|---|
visibility | не є дозволом: те, що зона ховає, може бути доступним за дозволом, і навпаки. Перше — оптимізація каналу, друге — безпека, а їх змішування означає, що налаштування туману війни мовчки розширює дозволи або що ACL починають застосовувати заради економії трафіку і права починають залежати від відстані |
the recipient | може знизити стелю: у межах оголошеного максимуму і ніколи нижче за оголошений мінімум, бо предикат однаковий для всіх, чий контекст збігся, а розмір пакета — проблема отримувача |
truncation | спостережуване: отримувач дізнається, що пакет обрізали і за яким порядком. Мовчазне обрізання заборонене — його не відрізнити від того, що об'єктів більше немає |
degradation | оголошена: коли бюджет на складання пакетів на кожного Actor'а вичерпується, платформа відкочується до спільного пакета як оголошено, а не починає губити отримувачів навмання: гірше, але відомим способом, замість витоку, що не відрізняється від бага гри |
packet shape | слабка обіцянка: розмір і склад пакета не повинні б дозволяти вивести існування прихованих об'єктів, і це навмисно слабше за «повинні» — повністю сховати метадані потоку на реальних обсягах недосяжно. Там, де витік існування важить, беріть дозволи, а не зону |
Помилки
- Неоголошена ціль підписки — валідаційна відмова.
- Немає дозволу підписатися — відповідь forbidden або not found залежно від того, чи є існування цілі таємницею: сама відмова не має видавати того, чому вона відмовляє.
- Позиція відновлення, яка не розбирається, — це поганий запит, а не мовчазний перезапуск із цього моменту.
- Підписка, яку закрила платформа, і вичерпана кількість підписок — обидві є конфліктами.
- Розширення огляду — це
fn. Видача розширеного огляду й задання ярусів деталізації сесії гравця відповідають forbidden, і її огляд не змінюється: клієнт-спостерігач не може розширити власну видачу. - Екземпляр, який огляд викликача виключає, відповідає
not found— тим самим, що й неіснуючий: forbidden підтвердив би, що за стіною щось стоїть. - Читання вартості пакета на Actor — це
fnadm: хмарна функція або панель, але ніколи клієнт, що питає, скільки коштує за ним спостерігати.
Обмеження
Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.
- Вартість пакета на кожного Actor'а — на вичерпанні оголошена деградація до спільного пакета з повідомленням, ніколи довільна втрата отримувачів.
- Об'єкти на правило — обмежені з оголошеним порядком і спостережуваним прапорцем обрізання.
- Підписки на Actor'а — нову відхиляють, наявні тривають.
- Розмір Delta — Delta розділяється, а не обрізається, і розділення спостережуване.
- Частота надсилання — верхня межа, а не гарантія.
Шлях користувача
Правило радіуса перетворює Room'у на 200 гравців у потоки околиці для кожного клієнта.
«Хто це бачить?» і «що вони бачать?» — обидва можна запитувати, бо дорогою є та сесія налагодження, у якій ви на них не відповісте. Вартість пакета на кожного Actor'а — це читання першого класу, і в коді, і в панелі.
Що переживає втрату хоста
Хост помирає посеред матчу. Матч — ні. Ця сторінка про друге значення слова «реплікація» — яка машина володіє Entity і яка володітиме нею наступною. Перше значення — який клієнт отримує який стан — це Visibility разом із Data і Prediction. Хто що бачить і яка машина це веде — це там, де ці два значення розрізняють.
Стан Room'и не копіюється між хостами
В Entity рівно один власник за раз, і жодна друга машина не тримає живої копії, готової перехопити.
Дві копії, що приймають той самий постріл, мусили б домовитися про порядок, у якому ці два постріли приземлилися. Домовлятися про порядок тридцять разів на секунду між машинами — це консенсус, а консенсус ставить затримку рівно туди, де гра її не терпить. У єдиного власника такої проблеми немає, і кожен механізм нижче існує, щоб єдиний власник виживав, а не щоб його обійти.
Що справді реплікується — це присутність: який Actor на якому вузлі. Це маленький факт, який змінюється повільно, тож маршрутизація може знати його всюди, не платячи за домовленість про щось рухоме.
Оголошений стан зберігається поза хостом
Оголошений стан не є приватним для процесу, який його тримає. Він знімається з оголошеним інтервалом, тож заміна може продовжити з останнього знімка, коли попередній хост перестає відповідати, а гравець заходить назад через звичайне пільгове вікно rooms.
Звідси випливають три речі, і це чесна форма цього:
- У заміни стан цілий, але станом на знімок. Повний, а не поточний. Заміна хоста коштує тієї гри, що була між останнім знімком і втратою, а інтервал і є тим, що фіксує цей найгірший випадок.
- Неперервність Tick'а не переноситься через зміну авторитету. Переміщення, яке виконує сама платформа, зберігає стан Tick'а учасника; заміна авторитету цього не обіцяє. Rooms — це там, де оголошують обидва разом із тим, що стається, коли пільгове вікно минає.
- Усе, що ви тримали лише в акторах рушія, іде разом із процесом. Воно ніколи не було оголошене, тож поза тим хостом його ніхто ніколи й не мав.
Деплой — це той самий шлях, тільки без утрат
Розвантажити хост — перестати розміщувати там нові Rooms, дати сесіям у польоті завершитися чи передатися, а далі відпустити його — це шлях заміни хоста, пройдений навмисно і з попередженням. Саме тому деплой без убивання живих сесій — не другий механізм, який треба будувати і якому треба вірити: це той самий, запущений свідомо, а не крахом.
Хост Room'и дізнається про це так само, як дізнається будь-що: платформа заздалегідь попереджає, що Room'у буде закрито або передано з причини на її боці.
Що стається, коли вікно минає, оголошено, і типового значення немає. Тип Room'и, чий авторитет живе поза платформою, називає один із трьох результатів його втрати — перечекати оголошене вікно, закрити Room'у або допустити авторитет-заміну. Промовчати — не той варіант, який пропонує Declaration, бо альтернатива — це саме той збій, задля запобігання якому воно й існує: Room із мертвим авторитетом, яка все ще приймає входи і тримає місця, показуючи кожному учасникові живу сесію, в якій нічого не відбувається.
Яка саме машина — не частина вашої поверхні
Ви ніколи не називаєте вузол. Той, хто створює Room'у, не обирає, де вона виконуватиметься, і жодна операція не бере хост аргументом — розміщення за платформою і лишається за платформою, щоб вона могла перенести Room'у без того, щоб ваш код був написаний під те, де вона була раніше.
Якщо ви хостите Rooms самі — виділений сервер чи master-client — усе те саме плюс одне: вам кажуть згортатися, і завершити чи передати свої сесії всередині пільгового вікна — ваша справа. Rooms — це там, де хост реєструється для цього зв'язування, а Авторитетність — це про те, чому хост тримає лише ті права, які йому дали.
Matchmaking
Завести гравця в потрібну Room'у. Тікети описують гравця і фільтрують інших. Матчмейкер визначає розміщення, резервує місце, а далі ігровий трафік іде прямо в Room'у.
Матчмейкер стоїть на шляху один раз, щоб вирішити, де ваше місце. На шляху матчу його немає: його наслідок — це розміщення і обмежена в часі бронь місця, а від входу і далі ігровий трафік іде просто в room. Тож завантажена черга ніколи не стає завантаженою грою.
Коли застосовувати
- Вам потрібно маршрутизувати гравців у rooms за оголошеними критеріями — режим, регіон, ранг, — а не самописним списком лобі.
- Критерії матчу мають походити з даних платформи, а не з заяви клієнта: штампуйте ранг у Hook'у до постановки в чергу.
- Черги мають розширюватися з часом на сервері, поки клієнт тримає один тікет і нічого не опитує.
- Паті мають потрапити в один матч разом — Group входить цілком або ніяк.
- Ви запускаєте зовнішній матчмейкер, і вам треба лише перетворити його рішення на розміщення + бронь місця.
- Не потрібно, коли гравці обирають сесію самі: перегляд rooms і
Joinце вже покривають.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | створює і скасовує власний тікет і входить у складі паті |
match-organizer | оголошує черги матчмейкера і послаблення; читає результати розміщення |
backend-service | штампує довірені критерії до постановки в чергу; виконує рішення зовнішнього матчмейкера |
Одним поглядом
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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'у до постановки в чергу:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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'ом |
outcome | RoomPlacement — посилання на 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, бо в тікета є власник: без сесії немає кого ставити в чергу.
Map
Статичний світ: межі, рельєф, перешкоди і «куди можна ставити речі?». Фізична модель навмисно значно простіша за візуальну: примітиви з footprint і висотою, шари з правилами і один запит допустимої позиції, що його перевикористовує кожен інший модуль.
Коли застосовувати
- Вам потрібен статичний світ — межі, рельєф, перешкоди, — який сервер може запитувати, а не лише рендерити.
- Спавни, дроп і декорації мають приземлятися в законних місцях: один запит
RandomPositionза правилами, без обхідних шляхів. - Арени мають перегенеровуватися на кожен матч — оголошений
Seedвідтворює ту саму карту в баг-репорті. - Ящики й стіни ламаються і повертаються — руйновні об'єкти з HP і таймерами респавну.
- Ботам і projectiles потрібні відповіді про промінь і лінію зору відносно набору перешкод.
- Не потрібно, коли світ суто візуальний і жоден серверний код не питає, куди можна ставити речі.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує карти, примітиви перешкод, руйновні об'єкти, шари та їхні правила |
room-owner | прив'язує карту до Room'и; просить позиції спавну; пускає промені |
operator | ставить чи прибирає перешкоди й шари з панелі |
Одним поглядом
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 assetAttribute-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
[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 assetScatter і Destructible — це генератори розміщення, а не кидки в рантаймі. Генератор розв'язується, коли публікують версію карти: сорок каменів стають сорока оголошеними примітивами, і опублікована версія несе примітиви, а не правило. Тому той самий Seed дає ті самі сорок каменів у матчі, у повторі й у баг-репорті, — а ліміти геометрії перевіряють один раз, на цьому розв'язаному наборі, до того, як версія дійде до Environment.
Запит, який ставлять усі інші:
RandomPosition: a fair spawn on ground, away from players, never repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});Модель
Два шари, які оголошують різні люди.
| Шар | Що він тримає і хто його оголошує |
|---|---|
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 values | managed, а не seeded: геометрія не є щоденним підкручуванням дизайнера — правку з адмінської консолі відхиляють, а не тихо тримають |
movement and contact | розв'язуються не тут: він відповідає, чим є простір; чи допустима позиція і яка відповідь — це справа collision, а застосувати це — справа locomotion |
Помилки
- Карта, версія чи екземпляр, яких не знайдено, відповідають not found, і відкликана версія так само — повторювати марно.
- Опублікувати змінену геометрію під наявною версією — це конфлікт: робіть нову версію.
- Збої публікації виявляються на оголошенні, на деплої, ніколи в рантаймі: карта понад ліміт примітивів, опукла оболонка понад свій ліміт вершин і довільний меш як перешкода — усе це валідаційні відмови до постачання.
- Запит висоти поза межами не є помилкою — це оголошена відповідь «поза межами», і вона відрізняється від «всередині перешкоди», бо в одному випадку клієнт розвертається, а в іншому обходить.
- Перевищена частота запитів відповідає в категорії рейт-ліміту зі строком.
Обмеження
Кожна стеля називає свою поведінку на краю; числа за ними надійдуть із розділом про обмеження платформи.
- Примітиви перешкод на карту, вершини опуклої оболонки, роздільність поля висот, розмір меж, місця на карту — кожне з них відхиляють на публікації, а не під час запиту: карта, яка постачається, — це карта, яка вже вміщується.
- Екземпляри карти на карту — створити ще один відхиляють як конфлікт; наявні екземпляри ніколи не відпускають, щоб звільнити місце.
- Скільки версій тримають — найстарішу застарілу версію відкликають, а версію під живою Room'ою — ніколи.
- Частота запитів до простору — рейт-ліміт зі строком.
Шлях користувача
Запланований авіадроп просить у карти законне місце, а гравець під'їжджає його забрати. Таблиця дропу — це пресет entity: Declaration на Entity, а не модуль, який ви монтуєте.
Collision
Прив'яжіть трансформ до карти перешкод; оголосіть, що робить контакт. Collision виконується всередині симуляції платформи. Ви оголошуєте тіла, шари й відповіді і підписуєтеся на контакти.
Коли застосовувати
- Рухомі entities мають розв'язувати контакти на сервері — ковзання, зупинка, відскок — без написаної руками процедури відхилення.
- Геймплей реагує на дотик: підбиранки збираються на перетині, тригерні об'єми запускають машину станів entity.
- Locomotion і projectiles потребують розв'язання заметанням відносно набору перешкод map.
- Попереднім переглядам розміщення чи прицілюванню потрібні «а чи влізе воно сюди?» і запити перетину з об'ємом.
- Не потрібно, коли фізично ніщо не зустрічається: геймплей запит/відповідь над записами — це звичайна Data.
Хто що робить
| Actor | На цій сторінці |
|---|---|
room-owner | оголошує тіла, шари й відповіді; запитує перетини й контакти |
До яких Room'ів це стосується. Модуль виконується там, де симуляцію крокує платформа, — у Room'ах, оголошених із Host = "Backend". Якщо симуляцією володіє ваш власний game server (PlayServ як метасервер), рух, колізії та передбачення лишаються на боці рушія, а ця сторінка описує розміщену на платформі альтернативу, а не вимогу.
Одним поглядом
Форма, шар і те, що робить контакт, — усе сидить на самому тілі; ніщо не оголошує пар шарів здалеку:
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
])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
[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 checked | stepwise — перевіряють кінцеву позицію кроку, швидко, і швидке тіло проходить крізь тонку перешкоду; або swept — перевіряють відрізок між позиціями, дорожче, і тунелювання виключене всередині кроку. Оголошується, ніколи не обирається реалізацією за швидкістю: чи може снаряд пролетіти крізь стіну — це властивість гри, а не оптимізація |
areas it participates in | усередині яких об'ємів його рахують |
its relation to the art model | жодного не вимагають: тіло — це спрощення, і розходження з артовою моделлю допустиме в оголошених межах |
Відповідь оголошують на пару — рід прохідності перешкоди × тип тіла — і вона походить із закритого набору:
| Відповідь | Що вона означає |
|---|---|
stop | рух припиняється на останній допустимій позиції |
slide | рух триває вздовж перешкоди тією складовою, яка допустима |
bounce | напрямок відбивають, а швидкість множать на оголошений коефіцієнт |
damp | рух триває зі швидкістю, помноженою на оголошену частку |
pass | перешкода не впливає на рух, але контакт усе одно спостережуваний |
cease to exist | Entity закінчується — снаряд об стіну |
Коефіцієнти — це оголошені значення, а не обчислені з мас і матеріалів: у цьому контракті немає ні тих, ні інших.
Що правдиве для кожної перевірки.
| Завжди | Що це |
|---|---|
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, а не модулі, які ви монтуєте.
Locomotion
Ви оголошуєте, як річ рухається; інтегратора не пише ніхто. Модель руху перетворює послідовний ввід на авторитетний рух, інтегрований із collision, записаний для prediction і модифікований бафами, дебафами й рельєфом.
Коли застосовувати
- Entities рухаються під вводом гравця — танки, персонажі, транспорт — і рух має бути авторитетним на сервері.
- Ви радше оголосите ліміти швидкості, прискорення й швидкості повороту, ніж писатимете інтегратор.
- Геймплей штовхає тіла: відкидання
Impulse,Teleportі модифікатори на кшталт багна з тривалістю. - Рух має відчуватися миттєвим: та сама оголошена модель крокує на сервері й у петлі prediction.
- Не потрібно, коли позиції змінюються лише дискретними кроками — синхронізоване поле на entity це вже покриває.
Хто що робить
| Actor | На цій сторінці |
|---|---|
schema-author | оголошує моделі руху, обмеження і зв'язування |
room-owner | застосовує імпульс, телепорт і модифікатори з хоста |
player | подає послідовний ввід; читає стан руху |
До яких Room'ів це стосується. Модуль виконується там, де симуляцію крокує платформа, — у Room'ах, оголошених із Host = "Backend". Якщо симуляцією володіє ваш власний game server (PlayServ як метасервер), рух, колізії та передбачення лишаються на боці рушія, а ця сторінка описує розміщену на платформі альтернативу, а не вимогу.
Одним поглядом
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)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
[Entity("tank")]
public class Tank
{
[Sync] public Vector3 Position;
[Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
public Motion Motion;
}Ввід клієнта — це послідовний намір. Платформа крокує рух:
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateСерверні дієслова:
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)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.
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 input | stop, 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, а не модулі, які ви монтуєте.
Prediction & Lag Comp
Гравець натиснув стрибок 50 мс тому. Пакет надійшов лише зараз. Він не впав. Передбачення вперед і компенсація назад над даними, що несуть свій справжній час події: клієнт відчувається миттєвим, сервер лишається правим, а влучання судять у часовій лінії стрільця.
Коли застосовувати
- Ввід має відчуватися миттєвим під затримкою, поки сервер лишається авторитетним, — передбачайте вперед, узгоджуйте у разі розбіжності.
- Влучання мають бути розсуджені в часовій лінії стрільця:
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 відповідно. Передбачення застосовує їх раніше або читає їх назад; воно ніколи не оголошує другої копії.
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 predictedAttribute-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
[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
}Розв'язання з компенсацією лагу відповідає на «де були всі, коли цей постріл зробили»:
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, тож обидві сторони малюють ту саму дугу з тих самих вхідних даних:
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
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, а не модулі, які ви монтуєте.
Передбачення власного руху
Це власна машина клієнта, і це єдиний із трьох механізмів передбачення, чиї помилки дешеві. Ви дієте за власним вводом ще до того, як сервер відповів, сервер відповідає, і там, де ці двоє не згодні, ваш клієнт виправляє себе сам. Помилка тут коштує невеликого візуального виправлення — і саме тому тут безпечно бути наполегливим.
Передбачення — це повторення оголошених правил, а не друга копія
Ваш клієнт не виконує паралельну реалізацію вашого руху. Він виконує ту саму оголошену модель, яку виконує платформа, — модель належить Locomotion, а передбачення лише застосовує її раніше. Це і є причина, чому обидві сторони здебільшого згодні: є один набір правил, застосований двічі.
А отже, немає ані операції «передбачити», яку треба викликати, ані операції «виправити». Передбачення стається тому, що аспект оголосили передбачуваним, а не тому, що ви щось викликали.
Що передбачається, оголошують на кожен аспект
Передбачуваність — це Declaration на аспекті entity, і це навмисно не глобальний перемикач:
- Аспект, який клієнт може обчислити, — позицію під вашим власним вводом, — можна передбачати.
- Аспект, який авторитет змінює за правилами, яких у клієнта немає, передбачати не можна. Якщо клієнт не може його вивести, здогадка про нього дає відкат, який гравець читає як брехню гри.
Ця лінія — там, де ви вирішуєте, що може мерехтіти, а що має бути правильним з першого разу.
Протокол виправлення і два числа, що надають йому форми
Авторитетний стан надходить, несучи номер останнього вводу, який він застосував, тож ваш клієнт точно знає, скільки його власного буфера ще не підтверджено. Далі:
- Прийняти авторитетний стан.
- Переграти буферизовані вводи, що прийшли після того, який він підтверджує.
- Узгодити результат із тим, що ви вже показували.
Два оголошені числа вирішують, як це відчувається. Поріг розбіжності: нижче за нього виправлення згладжується, вище — ваш клієнт стрибає і переграє. І ліміт буфера непідтверджених вводів: переповнення не є невизначеним — деградація оголошена і спостережувана, тож клієнт на поганому зв'язку знає, що він перестав передбачати, а не тихо пливе.
Розбіжність спостережувана для того клієнта, у якого вона була, і лише для нього. Ви можете дізнатися, що ваше передбачення виправили і наскільки, — це корисно для підкручування і для того, щоб показати гравцеві чесний індикатор зв'язку. Прочитати чиюсь чужу розбіжність ви не можете: розмір хибного передбачення — це інформація про їхній зв'язок, а не про гру. Виправлення живе на боці клієнта: стан авторитету — це те, що всім іншим уже показували.
Якщо ви приходите звідкись іще
- Mover 2.0 в Unreal. Форма знайома: вводи зі штампом Tick'а, модель руху, виправлення від авторитету. Різниця в тому, де живе модель: тут ви її оголошуєте, а платформа її симулює, тож нашого компонента руху, який можна успадкувати чи замінити, не існує.
- Netcode із відкотом і переграванням, як у Photon Fusion. Перегравання ваших власних непідтверджених вводів після виправлення — той самий механізм, і він тут повністю. Чого тут навмисно немає — це повторне відтворення світу заднім числом; що відбувається натомість і чому, див. у Компенсації лагу.
Чого це не покриває
Entities інших гравців не передбачають, їх відображають — це Показ інших гравців. Розсудити постріл у часовій лінії стрільця — серверний механізм, і він живе в Компенсації лагу. І жоден із трьох не діє взагалі, коли режим авторитету Room'и зовнішній: тоді Tick належить тому, хто його веде, і передбачення теж.
Показ інших гравців
Ніхто не передбачає інших гравців — їх відображають. Ви отримуєте їхній стан з інтервалами, і щось у цих проміжках вам треба намалювати. Помилка тут нікому не коштує життя; вона коштує видимого ривка, і саме тому вона має власні Declarations, а не ділить їх із Prediction.
Режим показу оголошують, а не вгадують
Для entities, які не ваші, Room оголошує, чим заповнювати проміжок між станами, що приходять: інтерполювати між станами, які у вас є, або екстраполювати за найновіший. Це Declaration на Entity, тож відповідь однакова на кожному клієнті й не пливе разом із тим, хто реалізував рендерер.
Затримку інтерполяції теж оголошують. Показувати інших гравців плавно означає показувати їх трохи пізно, на оголошену величину. Назвати число — це й є суть: неназвана затримка — це баг-репорт, який ви не відтворите, а названа — це дизайнерське рішення, яке ви можете підкрутити під свій жанр.
Екстраполяція зупиняється, а не вигадує
Вікно екстраполяції оголошене, і за ним Entity перестає показуватися рухомою, а не їде далі за здогадкою. Екстраполювати нескінченно — це посадити гравця стріляти в ціль, якої там ніколи не було, і помітити це він не може; видиме замерзання — це та поломка, з якої є вихід.
Чому це окремо від передбачення власного
Три механізми передбачення мають різні авторитети й різні режими відмови, і одне слово на всі три означає, що підкручування одного мовчки змінює два інші.
| Механізм | Виконується на | Коли він хибний |
|---|---|---|
| передбачення власного | клієнті | виправлення, перегране і згладжене |
| показ інших гравців | клієнті | видимий ривок |
| компенсація лагу | сервері | хтось помирає несправедливо |
І саме тому існує пресет observer, що несе цей механізм і більше нічого: у спостерігача немає власного вводу, який можна передбачати, тож дати йому налаштування передбачення означало б налаштовувати те, чого він не робить.
Компенсація лагу
Це серверний механізм, і єдиний із трьох, чиї помилки когось убивають. Коли він судить хибно, гравець помирає несправедливо — і на користь того, у кого гірший зв'язок. Усе на цій сторінці має форму, яку йому надала ця асиметрія.
Питання, на яке він відповідає, вузьке: що саме бачив стрілець? Дія може нести час погляду — Tick, на який дивився Actor, коли діяв, — і платформа відновлює пози цілей на цьому Tick'у, тож постріл судять за тим, що було на їхньому екрані.
Час погляду — це твердження, а не факт
Він приходить від клієнта, тож це заява викликача, і з нею поводяться саме так. Два наслідки:
- Вікно компенсації обмежене, і поза ним платформа відмовляє. Вона не екстраполює, щоб бути корисною. Відмова — це рішення, яке ви бачите; мовчазна екстраполяція — рішення, якого ви не бачите.
- Читання минулого стану цілі все одно кориться Visibility. Питання про історичний Tick — це не спосіб обійти Visibility: чого ви не могли бачити тоді, того не прочитаєте зараз.
А «влучання не зарахувалося» — це вердикт, а не помилка: успішна відповідь із машинозчитуваною причиною. Ваш код поставив законне питання і дістав законне «ні».
Що відкочується, оголошено, і це не все
Відкочувати все звучить послідовно й дає подвійні вбивства: двоє гравців стріляють одне в одного, обох відмотують до моменту, коли обидва живі, обидва влучають. Не відкочувати нічого скасовує саму компенсацію лагу. Межа між ними — це оголошений список, а не інтуїція реалізації.
Саме відмотування належить сюди, а не модулям, які відмотуються. Кільце історії відновлює пози спірного Tick'а, а далі Collision питають її звичайне питання про перетин цих поз — колізія не тримає власної історії, і ніщо в ній не знає, що таке Tick погляду. Саме кільце — це доріжка історії entity, а не друге сховище.
Рішення ухвалюють на минулому; ефект застосовують у теперішньому
Компенсація лагу відповідає на питання про мить погляду стрільця. Наслідки — шкода, смерть, зарахування — застосовуються до поточного стану. Те, що сталося між миттю погляду і миттю рішення, не скасовують і не переобчислюють.
Тож це спостережувано, і так і задумано: гравець може встигнути вистрілити після того, як його вбив чийсь відмотаний постріл. Скасувати це означало б переграти світ поверх відмотування, яке не обіцяє відтворюваності, — а це виготовляє розбіжність замість того, щоб її прибирати.
Пересимуляція на боці сервера навмисно поза межами. Переобчислення наслідків щодо нової правди потребує нерухомої точки відліку, якої стан із рухомою комою нам не дає. Лишається все, на чому модуль стоїть: клієнт, що переграє власні непідтверджені вводи (Передбачення власного руху), і компенсація лагу як читання минулого заради одного рішення. Саме так на практиці працює «на користь стрільця».
Якщо ви приходите звідкись іще
- Компенсація лагу на користь стрільця, як її постачає більшість змагальних шутерів: той самий механізм, і ця сторінка — про нього.
- Повний rollback-netcode. Відмотування тут є; перегравання світу після нього — ні, і абзац вище пояснює чому. Якщо ваш дизайн залежить від переобчислення наслідків заднім числом, цю залежність варто підняти з нами рано, а не виявити пізно.
Ще варто знати
- Реалізацію можна перевизначити. Якщо вашій грі потрібне інше правило компенсації, ви можете замінити наше, а заміна оголошує, яких із Declarations вона дотримується.
- На шляху передбачення і виправлення точок розширення немає. Вони виконуються з частотою Tick'а, і Hook у цій петлі був би Hook'ом, якого ви не можете собі дозволити.
- Нічого з цього не діє під зовнішнім авторитетом. Компенсація лагу існує для Rooms, які веде наша симуляція. Коли Tick належить ігровому серверу студії чи master-client'у, компенсація належить тому, хто його веде, — див. Хто веде Tick.
Bots
Бот входить як звичайний гравець. Деінде живе лише мозок. Та сама сесія, та сама валідація входу, ті самі правила, той самий ACL. Room не бачить різниці, і це задумано, тож боти проганяють ваші справжні правила гри, а античітові ніколи не потрібен виняток для бота.
Коли застосовувати
- Ваші лобі треба наповнювати в години спаду —
FillRoomдобирає матчі до квоти, а боти поступаються місцями, коли приходять люди. - Боти мають грати за справжніми правилами — валідація входу, ACL, visibility, — щоб античітові ніколи не був потрібен виняток для бота.
- Ви приносите зовнішній мозок — навчену політику, сервіс, — який входить через
ConnectAsBot, як будь-який гравець. - Entity відпалого гравця має передаватися ботові й назад на перепідключенні так, щоб цього не помітили ані місце, ані prediction.
- Не потрібно, коли персонаж нічого не вирішує: діалоговий NPC без мозку живе в World Objects.
Ця сторінка — половина про підключення. Як завести бота в Room'у, наповнити лобі до квоти, передати місце між ботом і людиною. Написати те, що вирішує, — це друга половина, Як написати мозок, яка специфікує роз'єм, у який втикається мозок.
Хто що робить
| Actor | На цій сторінці |
|---|---|
bot-brain | підключається як гравець; отримує сприйняття; надсилає команди |
room-owner | оголошує профілі, наповнює Rooms до квоти, передає бота/людину |
Одним поглядом
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 увесь час працює за справжніми правилами.
Як написати мозок
Мозок — це звичайний код, який відповідає на одне питання: що цей бот робить далі. Він виконується там, де ви захочете, — хмарна функція, ваш власний сервіс, headless-клієнт, — і розмовляє з Room'ою через ту саму поверхню, якою користується клієнт живого гравця. Ця сторінка специфікує роз'єм, у який він втикається: що мозок отримує, що йому дозволено надсилати назад і коли. Підключення бота покриває другу половину: як завести бота в Room'у.
Що вирішено і на чому можна будувати вже сьогодні
Платформа не постачає ігрового ШІ. Ані дерев поведінки, ані utility-системи, ані навігаційного мозку. Це не прогалина, що чекає на заповнення, — це межа. Рішення ваші, а робота модуля — зробити так, щоб ваші рішення не відрізнялися від рішень гравця.
Мозок — не Hook. Hook огортає наш крок. Мозок не є нашим кроком узагалі: він виконується поза Room'ою, за власним розкладом, і платформі байдуже, з якого боку відкрили з'єднання. Саме тому мозок може бути хмарною функцією, сервісом, який хостите ви, або headless-клієнтом — і саме тому жоден із них не рідніший за інші.
Роз'єм — це сприйняття всередину, команди назовні, і обидві сторони навмисно належать гравцеві:
| Що це | |
|---|---|
| сприйняття | рівно те, що отримував би гравець на цьому місці — ті самі Deltas, крізь ті самі правила visibility. Бот не може бачити крізь стіни так само, як не може гравець. |
| команди | рівно те, що надсилав би гравець на цьому місці. Привілейованого каналу вводу не існує. |
Якщо грі справді потрібен бот, що бачить більше — режим налагодження, режим тренування, — це оголошене розширення, а не побічний ефект того, що він бот.
Tick мислення оголошений, і це не Tick симуляції. Мозки зовні, тож вони думають у власному ритмі. Між двома думками діє остання команда — і саме це найважливіше, під що треба проєктувати, бо воно означає, що мозок, який думає повільно, дає не бота, який стоїть на місці, а бота, який далі робить те, що вирішив востаннє.
Зникнення мозків має оголошену поведінку, і типового значення немає. Ви кажете, що стається, коли мозок перестає відповідати, для кожного типу Room'и. «Мозки недоступні» — це утримуваний Event, тож той, хто підписався пізно, дізнається про поточну ситуацію, а не лише про майбутні зміни.
Чим бот навмисно не може бути
Варто прочитати, перш ніж проєктувати навколо цього, бо це відмови, а не пропуски.
- Бот не гравець, і він не володіє правами, покупками чи записами Leaderboard. Бот, який міг би їх тримати, був би способом їх виготовляти.
- Прапорець бота існує завжди і завжди спостережуваний для платформи. Чи показує його ваша гра гравцям — ваше рішення; чи існує він — ні.
- Модуль не тримає історії того, що бот вирішив і чому. Це ваша справа, у вашій телеметрії — ми не збираємося ставати місцем, де зберігаються міркування вашого ШІ.
Auth & Players
Вхід — це крок, який можна перевизначити, а не чорна скринька. Провайдери, сесії, зв'язування осіб, бани. Кожна точка потоку — до і після входу, до і після зв'язування, до і після злиття, на зміні статусу — це оголошена точка розширення з оголошеним родом: ворота, які можуть відмовити в кроці, або спостерігач, який не може.
Коли застосовувати
- Гравці мусять входити — пристрій, пошта, Apple, Google, Steam чи власний — зі створенням на першому вході як прапорцем, а не другим потоком.
- Гостьовий акаунт має пізніше піднятися —
Linkдодає Steam із цілим прогресом, а злиття зводять два акаунти в одного гравця. - Політика має виконуватися там, де її не оминеш, — регіональні ворота до входу, стартовий набір після того входу, який створив гравця.
- Модерації потрібні зуби — відкликати сесії, призупинити, забанити пристрій, з Event'ом
banned, який кожна жива система чує одночасно. - Оголошений контекст (регіон, платформа, збірка) має доходити до кожного наступного Hook'а без того, щоб кожен перечитував гравця, аби це дізнатися.
- Нічого легшого, на що можна перескочити, немає — кожен інший модуль називає свого викликача через цей, і
authне можна вимкнути, поки будь-кому з них потрібен Actor-гравець: конфігуратор модулів відмовляє і називає залежних.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | входить, зв'язує чи відв'язує осіб, оновлює, виходить |
moderator | відкликає сесії; банить, призупиняє або відновлює гравців |
backend-service | закриває вхід за регіоном; засіває перші рядки нового гравця; читає і відкликає сесії |
Одним поглядом
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identitiesКожну точку налаштовують там, де її оголошено; форми, яких може набувати обробник, зібрані в Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
Оголошена форма — це те, що рендерить панель: кожна точка показує свої обробники, їхній рід і розв'язаний порядок. Рід — це та частина, у якої є зуби:
| Рід | Коли падає сам обробник | При відмові |
|---|---|---|
| ворота | крок відхиляється: недосяжна перевірка регіону не є пройденою перевіркою регіону | код із каталогу платформи плюс людська причина. Викликачі розгалужуються за кодом; текст причини вільно змінюється і перекладається |
| спостерігач | крок лишається зробленим, тож стартовий набір, який не застосувався, коштує скрині, а не входу | відмовити він не може |
Чого не має права робити жоден обробник — це вирішувати, хто увійшов. Ворота відповідають «так» чи «ні» щодо особи, яку платформа вже встановила; вони не називають гравця, не роздають особи й не стоять замість підтвердження провайдера. Ця лінія і є різницею між входом, який можна перевизначити, і входом, який можна оминути.
Модель
Гравець — це носій особи, а не рядок у вашій схемі, і його ідентифікатор стабільний і ніколи не перевикористовується — зокрема й на злитті: 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 пізніше з цілим прогресом.
Profile
Profile — це подання, і платформі не належить майже нічого з нього. Те, що платформа тримає про гравця, — це player_id і системний профіль за ним: особи, сесії, зв'язки з провайдерами, усе це в Auth. Усе, що гравець має, — це ваша власна Entity, яка належить цьому гравцеві. Profile — це набір тих entities, які оголошує ваш проєкт, прочитаний для одного власника за один прохід.
Коли застосовувати
- Екранові потрібен зріз одного гравця одним викликом — оголошений набір розходиться по його власних 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-sideAttribute-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
[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 — це назва того читання, а не модуль, що стоїть за ним: ті самі права, ті самі предикати, ті самі фільтри, та сама підписка, бо це та сама операція.
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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, а не оголошує власні.
Шлях користувача
Від малювання лобі до перескоку рівня: серверний запис доходить до підписаного екрана, і екранові не треба питати знову.
Social
Одне нове поняття, а все інше побудоване з того, що у вас уже є. Стосунок двох Actors, з власним станом і ініціатором, — це все, що додає цей модуль. Клан, гільдія чи загін — це group з шаром стосунків над нею, а не другий рід речей; а блокування, яке потрібне кільком модулям, живе тут, щоб ним володіло рівно одне місце.
Коли застосовувати
- Гравці потрібні одне одному на ім'я — друзі, підписники, списки блокування.
- Кланові чи гільдії потрібні двері — запрошення від 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'а — рейт-ліміт із часом.
- Частота змін присутності в потоці — обмежена частотою оновлення, а не викиданням змін.
- Зберігання відхилених і розірваних стосунків — прибирання за оголошеним періодом.
Шлях користувача
Messaging
Rooms, Groups, гравці: одна модель адресації для чату і сповіщень. Повідомлення приходять у розмову; розмови — це channels з історією, модерацією і позасмуговою доставкою поверх.
Коли застосовувати
- Гравці розмовляють — чат Room'и, канали гільдії, приватні повідомлення — поверх адресації, яка у вас уже є: Room, group, гравець.
- Офлайн-гравці все одно мають почути — шаблонні сповіщення з розкладом доставляються позасмугово пушем.
- Модерація має відпрацювати до доставки — Hook перед надсиланням фільтрує або відхиляє, а заглушення/блокування скрізь забезпечує платформа.
- Гравцям, що повертаються, потрібне надолуження —
History(take: 50)читає розмову сторінками на наступному запуску. - Не потрібно, коли payload — це ігровий стан, а не розмова: синхронізовані поля в Data і channels core це вже розсилають.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | надсилає й отримує повідомлення; читає історію; глушить або блокує |
moderator | фільтрує, маскує і банить терміни |
backend-service | надсилає або планує шаблонні сповіщення |
Одним поглядом
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)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.
// 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")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.
[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, ніколи з сесії гравця:
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, той самий контракт, що й усюди:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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, який дістає пуш і читає клич з історії на наступному запуску.
Catalog & Commerce
Предмети, ціни, гаманці, вітрини, покупки, права. Справжні інтеграції з магазинами там, де платформи це дозволяють (Stripe, App Store, Google Play, Steam, Xbox); вітрини з розкладом і націленням на аудиторію; і потік покупки, на кожен крок якого можна поставити Hook.
Коли застосовувати
- Ви щось продаєте — за справжні гроші через Stripe, App Store, Google Play, Steam чи Xbox або за валюту гаманця.
- Вітрини мають розв'язуватися на кожного гравця — розклад, аудиторія і ціна обчислюються на сервері, а не арифметикою прийнятності в клієнті.
- Правила ціноутворення належать одному Hook'у, який можна протестувати, — знижки, переоцінка і вето виконуються до будь-якого списання.
- Чеки мають бути стійкими до повторного програвання, а повернення має відкликати право тими самими Events, якими користувалася видача.
- Не потрібно, коли предмети ніколи не продають, — хоча нагороди все одно надходять через один
Grantкомерції з походженнямreward(циклові скрині Leaderboards з'являються саме так), тож навіть гра без магазину тримає єдиний реєстр видач, придатний для аудиту.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | переглядає вітрини, купує, керує гаманцем, погашає коди |
seller | налаштовує каталог, ціни і розклади вітрин |
backend-service | валідує чеки; переоцінює або видає через Hooks покупки |
Одним поглядом
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | це авторський контент, адресований ключем, тож перейменування в коді є перейменуванням |
kind | consumable — його витрачають; або 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 | предикат, а не список гравців, — тож аудиторія — це правило, яке лишається істинним, а не знімок |
Вітрина, в чию аудиторію гравець не потрапляє, для нього не існує.
Стани замовлення.
| З | У |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid або granted | refunded |
| Завжди | Що це |
|---|---|
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.
Inventory
Тут інтегрується все. Постріли списують набої, дроп потрапляє сюди, здібності це перевіряють, рух цим модифікується — один власний набір рядків, зі стеками, що інкрементуються, і стелею на власника, чию поведінку на краю обираєте ви.
Коли застосовувати
- Гравці щось тримають, і володіння — це рядок із власником: читається за власником, обмежується на власника, з оголошеною, а не типовою поведінкою на переповненні.
- Кількість накопичується — стек змінюється інкрементом із ключем ідемпотентності, тож повторене списання не списує двічі.
- Інші модулі витрачають з одного набору — постріли списують набої, дроп видає здобич, покупки з'являються рядками відповідно до свого права.
- Не потрібно, коли число не можна тримати у власності — hp, xp і кулдауни належать статам.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | читає власні володіння і витрачає з них |
backend-service | видає, інкрементує і відкликає від імені гравця, називаючи гравця, за якого діє |
Одним поглядом
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)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, а не власні модулі.
Leaderboards
Кожна механіка, систематизована. Не каталог типів таблиць. Одна модель, чиї осі складаються в усі з них: денні таблиці, таблиці кращого кола, суми гільдій, сезони, турніри.
Читайте той блок так: хто діє на цій сторінці (actors), що модуль вам дає (provides), на яких модулях він стоїть (builds-on) і де він висить відносно кореня — mounts: root означає playserv.Leaderboards, а не простір імен під іншим модулем (як монтуються модулі).
Коли застосовувати
- Рахунки мають ранжувати гравців — денні таблиці, таблиці кращого кола, суми гільдій — однією оголошеною моделлю, а не системою на кожну таблицю.
- Вам потрібні стандартні читання — топ-N, навколо-мене, іменований список власників — без додаткового моделювання даних.
- Цикли мають закриватися за розкладом, архівуватися (ніколи не видалятися) і запускати Hook нагороди з фінальною таблицею.
- Підозрілі рахунки ніколи не мають потрапляти в таблицю — передвідправний Hook валідує, зрізає або відхиляє з типізованою причиною.
- Турнір — це та сама таблиця з вікном вступу, максимумом учасників і спробами на цикл.
- Не потрібно, коли число ніколи не порівнюють між гравцями: особистий лічильник чи кар'єрна сума — це звичайні дані. Модуль упорядковує результати; він ніколи їх не обчислює і не веде сітки на вибування.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | читає топ-N/навколо-мене/власний ранг, підписується на зміни рангу |
backend-service | відправляє результати; виправляє чи відхиляє їх у передвідправному Hook'у; видає нагороди, коли цикл закривається |
operator | оголошує таблиці; закриває цикл раніше, виправляє записи (з аудитом), стежить за частотами відправок |
Одним поглядом
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 itAttribute-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
[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:
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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: неоголошене поле відхиляється, а не зберігається.
Читання, потрібні кожній грі, і підписка, що тримає їх актуальними:
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("weekly-score", 5) — це вікно за рангом, а не сторінка: п'ять рядків вище за вас, п'ять нижче, плюс власний — одинадцять рядків, підрізаних симетрично там, де таблиця закінчується, тож ранг 2 дістає коротше вікно з обох боків, а не зсунуте. Top розбитий на сторінки: він повертає перші N рядків і курсор, а after: проходить решту.
ForOwners — це те, як працює таблиця друзів. Платформа не тримає графа друзів; ви передаєте власників, які у вашої гри вже є, — членів group або список id із ваших власних даних, — і кожен рядок повертається зі своїм рангом у повній таблиці, а не з рангом усередині списку.
OnRankChanged доставляє власний ранг локального гравця і більше нічого: таблиця з п'ятдесятьма тисячами учасників не надсилає кожне перетасовування кожному клієнтові. Зворотний виклик отримує змінений рядок — ранг, ранжовані поля, поля показу, — а Cancel() закінчує підписку. Сам ранг — це знімок: два читання з інтервалом у секунду можуть різнитися, поки надходять відправки, хоча ваша власна відправка завжди видима вашому власному наступному читанню.
Модель
Що оголошує таблиця.
| Вісь | Значення | Як ви це задаєте |
|---|---|---|
| Власник | гравець · group | Owner = Owner.Player — таблиця гільдій — це та сама таблиця з Owner.Group |
| Ключ порядку | одне чи більше оголошених полів, кожне за зростанням або спаданням | [Rank(1, Sort.Descending)] int Score |
| Агрегація | set · best · increment · decrement | Agg = 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 reset | закриває цикл, а не видаляє його |
a closed cycle | перестає приймати відправки і лишається доступним для читання під своєю міткою — Top("weekly-score", 100, cycle: label), параметр читання, а не задача експорту |
the close event | несе цю мітку, тож обробник читає рівно ту таблицю, яка закрилася, а не порожню, яка щойно відкрилася |
Два Hook'и на таблиці, і їхні види різні.
| Hook | Що він може |
|---|---|
pre-submit | gatekeeper: платформа викликає його і чекає. Він може виправити відправлені значення відносно ваших власних entities, зрізати їх або відхилити з типізованою причиною, а якщо він дає збій, то відправку відхиляють — fail-closed. Він не може змінити ані власника запису, ані його таблицю: вони вже заявлені. Він повертає вердикт — прийняти, прийняти виправлену відправку або відхилити, — і відхилення доходить до викликача типізованою проблемою (Core), у тій самій формі, якої набуває кожна відмова в SDK |
cycle-closed | observer: спрацьовує постфактум, накласти вето не може, і збій там лишає цикл закритим |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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: серверні відправки, читання навколо-мене, понеділкове закриття і його нагороди.
Files & UGC
Файли приходять шматками і обробляються в міру надходження. Вивантаження, ассети і їхні похідні варіанти, а також створений гравцями контент зі шляхом модерації.
Коли застосовувати
- Гравці чи сервіси вивантажують блоби — шматками, з відновлюваними сесіями і доступними для читання квотами на гравця.
- Обробка має початися до кінця вивантаження — читайте файл потоком, шматок за шматком.
- Створеному гравцями контенту потрібен шлях модерації —
SubmitUgc, черга, вердикт, Hooks на обох кінцях. - Одне майстер-зображення має обслуговувати багато платформ — виводьте варіанти (масштабування, транскодування), а оригінал лишайте канонічним.
- Не потрібно для маленьких структурованих payload'ів — поле запису Data несе їх без сесії вивантаження.
Хто що робить
| Actor | На цій сторінці |
|---|---|
player | вивантажує шматки, читає файли потоком, подає UGC |
moderator | переглядає чергу, схвалює або відхиляє подання |
backend-service | виводить варіанти ассетів; чіпляє Hooks на вивантаження й модерацію; задає квоти |
Одним поглядом
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clВорота навколо нього — це Hooks, той самий контракт, що й усюди:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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'ом.
Шлях користувача
Один рівень, побудований гравцем, від першого вивантаженого шматка до вердикту про схвалення.
Analytics
Усе, що треба порахувати потім, а не побачити зараз. Оголосіть типізований Event телеметрії, випустіть його — і він стане поруч із власними подіями платформи: пройдений рівень, крок воронки, економічна подія, тривалість сесії, відтік у навчанні. Цей модуль випускає; він не читає, не агрегує і сам нікуди нічого не відправляє — напрямок, у якому йде пакет, належить маршрутизатору, в Extensibility.
Коли застосовувати
- Щось треба порахувати потім — крок воронки, пройдений рівень, економічну подію, тривалість сесії.
- Порівняння має пережити збірки гри — тип несе версію схеми, тож річна воронка не є мовчки склейкою двох різних значень одного поля.
- Обсяг великий, і втрачений рядок прийнятний, якщо ви так сказали, — телеметрія є єдиним місцем у контракті, де оголошена втрата законна.
- Не потрібно, коли хтось має зреагувати: у Event'а телеметрії немає підписників узагалі; факт, який інші мусять почути, — це ігровий Event.
Хто що робить
| Actor | Може | Не може |
|---|---|---|
any actor | оголошувати типи у схемі; випускати від свого імені, по одному або пакетом; читати оголошені типи | заповнювати контекст; читати, запитувати чи агрегувати випущене |
backend-service | те саме, плюс випускати від імені гравця за делегуванням | читати телеметрію — дозволу на читання немає, бо немає операції читання |
Одним поглядом
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))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.
[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, а не модулі.
Операторська площина
Те, чого в SDK навмисно немає. Життєвий цикл Project'ів і Environment'ів, деплой і відкат, білінг, адміністрування організації та користувачів, маршрутизація кластера — усе це належить адмінській панелі, CLI та поверхні MCP, а не ігровому коду. Єдиний навмисний виняток — Schema as Code: схема — це поверхня для розробника, тож вона в SDK.
Одна модель, дві площини
| Площина SDK | Операторська площина | |
|---|---|---|
| Досяжна з | ігрового коду | Control Panel, CLI, MCP |
| Тримає | Rooms · entities · гравців · комерцію · Leaderboards | Projects і Environments · деплой і відкат · білінг · адміністрування організації та користувачів · маршрутизацію кластера |
| Працює в | Declarations, Hooks, Events, Operations | власних екранах панелі |
Вони ділять одну модель: Declaration, яке ви відправляєте, — те саме, яке рендерить панель. Через лінію не переходить авторитет: ігровий код не може деплоїти, виставляти рахунки чи переносити тенанта.
Кожна поверхня SDK — кожен модуль і пресети, оголошені на entities, — має операторського двійника в Control Panel, де ті самі Declarations переглядають і правлять з іншого боку:
| Поверхня SDK | Оператор бачить |
|---|---|
| Schema / Data | entities, міграції, перегляд записів, збережені подання, імпорт/експорт |
| 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.
Під капотом: транспорт і хаб
Архітектурний довідник, а не поверхня, яку ви викликаєте. Ніщо на цій сторінці не з'являється в API, під яке ви пишете: немає ані сокета, який треба відкрити, ані каналу, який треба обрати, ані конверта, який треба заповнити, ані повтору, який треба запланувати. Ваш ігровий код ніколи не зустрічає механіки цієї сторінки — у цьому й суть. Основні поняття називають стек; механізм живе лише тут. Він тут, щоб архітектор міг перевірити, що SDK робить з обірваним з'єднанням, вимкненим модулем чи повідомленням, яке має прийти рівно один раз.
Стек шарів
П'ять шарів, згори вниз: простір користувача, модулі, 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 та від інших модулів. Збірка, якій модуль не потрібен, його не монтує. Монтування має простори імен, і другий модуль, що претендує на вже зайняту точку монтування, відхиляється під час монтування: композиція падає там, а не на першому виклику до неї.
Оскільки модулі утворюють граф, вимкнення одного має наслідки далі по ланцюжку, і хаб іде рівно одним із двох шляхів:
- Вимкнути залежний ланцюг. Кожен модуль, якому потрібен відсутній, теж вимикається, і його інтерфейси відсутні, а не падають.
- Оголосити деградовану функціональність. Залежні лишаються змонтованими і повідомляють, чого вони більше не можуть робити.
Третього шляху немає. Мовчазне напівпрацювання — змонтований модуль, що тихо викидає операції, яких більше не виконує, — це саме той режим відмови, якому це правило й покликане запобігти, і саме тому вимкнена залежність спостережувана, а не загадкова.
Чому ви нічого з цього не зустрінете
Кожну обіцянку зі сторінок модулів дотримано вище цієї лінії: мутація Entity і є мережевою операцією, Hook — це типізована функція, вхід — це один виклик. Назви зі стека можуть до вас дійти — Основні поняття вказують сюди, — але обіцянка в тому, що ви ніколи нічого з цього не викликаєте, а не в тому, що слова таємні. Шари нижче існують, щоб ці обіцянки пережили зміну транспорту, і сторінка, яку вам ніколи не доведеться читати, — це міра того, що воно працює.
Те, що ви зустрінете, — контекст доставки, на якому виконуються ваші обробники, момент, коли закінчується хендл, і in-memory реалізація, на якій ви тестуєте, — це на сторінку вище: Потоки, час життя і тестування.
PlayServ SDK
随玩法一起交付的游戏后端。 PlayServ 是面向在线游戏的后端即服务:一家工作室运行自己游戏的后端——数据、玩家、Rooms、matchmaking、商业化——却不必自己托管一套。而 SDK 就是你的代码(无论在服务器上还是在引擎里)与这个平台打交道的方式。
本页是一份短清单,列出这里到底有什么不一样。下面的每一项都是为一个 Project 决定一次、随后靠配置而非编写来落地的;你真正调用的东西写在各个模块页上,而这里的每一节结尾都会点名拥有它的那个模块。
模拟不是你的代码
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 是最短的入口。
Getting Started
一个能玩的竞技场(地图、坦克、射击、掉落),从头到尾都是声明出来的。下面没有一处是游戏循环:模拟在平台内部运行,而这就是全部的代码。
开始之前。 你需要一个带 dev Environment 的 Project(在运营平面上创建,那个生命周期归它管)、一个已登录到该 Project 的 playserv CLI,以及你所用绑定的 SDK 包——除此之外,什么都不会装进你的游戏。
用户流程
你发起的每一次调用都是下面的示例之一;它们之间的那些步骤,是平台在按某份 Declaration 说的话行事。图里的 ability、stat 和 drop-table 都是 Entity Presets——它们是 Entities 上的 Declarations,而不是各自独立的模块。
1. 声明这个世界
Entities 是你的 schema 加上它们的实时切面。每种行为一个特性,就写在它所描述的字段旁边:
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)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
[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、StatEvent | Hook 的载荷,由你挂钩的那个模块递进来 |
Seat | Matchmaking |
Scope 以及各种同步作用域 | Visibility |
Tick 以及各档 tick 速率 | Rooms |
这些枚举是封闭的。没有任何成员能覆盖的规则,写成谓词而不是新增一个成员:当 Scope.Owner 并不完全是你想表达的那条规则时,[Aspect("loadout", Visible = "owner == caller.player")] 就是按字段表达可见性的写法(Data & Subscriptions)。
2. 声明这个 Room
一个 Room 模板说明一场会话是什么,并点名它所依赖的那些 Declarations。没有 Room 类要继承,也没有 tick 方法要填,因为 Room 的内部实现是平台的:
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)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
[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、railgun | catalog 条目(Catalog & Commerce)——射击扣弹药、拾取落进包里,走的也是这条路 |
battle、arena、crate-loot、shell | 这四份 Declarations 各自注册的键 |
playserv push 会拒绝一份其键在目标 Environment 中并不存在的 Declaration,所以一个打错的键会在部署时失败,而不是在第一次开火时才失败。不管这份模板写在哪里,都不必重新部署引擎就能重新调参:推上去的那个模型,就是 live-ops 在面板里编辑的东西。
3. 把你的规则写成 Hooks
Hooks 是平台在被命名的步骤上调用的云函数。输入有类型,输出有类型——没有什么塞满杂物的 context 对象,签名里也没有 logger:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 的大脑、一次压测、一个运维工具):
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)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.
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)。
接下来去哪儿
- 从示例学起,也就是紧接着这里的那一节:Tanks 里的 Leaderboard、Tanks 里的血包,或者讲大循环(meta)的每日锦标赛——每篇一个真实功能,每一步都链到拥有你刚用过那样东西的模块页。
- SDK 如何工作,等到它的整体形状开始比下一个功能更要紧的时候:核心概念是那本词典,另有四篇文章分别回答是谁在调用(权威性)、一次授权怎么写(Access & Roles)、SDK 由什么构成(SDK 是怎么建起来的)和它是怎么被执行的(线程、生命周期与测试)。
- 然后是各个模块。每个模块页的骨架都一样——论点、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 → 丢掉一台主机之后什么还在 |
Tanks 里的 Leaderboard
Tanks,也就是 Getting Started 里那个示例竞技场,还没有 Leaderboard。这一课你在读完 Getting Started 之后随时都可以上,它用三步加上一块每周击杀榜:声明这块榜、从击杀 Hook 里提交、在客户端读它。每一步都链到拥有你刚用过那样东西的模块页,所以这一课靠指路来教,而不是靠重复。
第 1 步——声明这块榜
一块榜就是一份 Declaration:按哪个字段排名、重复提交如何合并、什么时候重置、以及谁可以提交。Aggregation.Increment 会把每次提交累加到累计总数上,所以一次击杀就是一分。Submit.ServerOnly 是默认值,它把这块榜对客户端关上,这也正是第 2 步成为唯一入口的原因。
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)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
[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 是一个云函数,输入有类型、输出有类型,所以提交一次击杀在它里面就是一行。
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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:榜首那一段,以及本地玩家周围的那个窗口——上五行、下五行,加上你自己。两者都以带击杀数和显示名的排名条目返回,可以直接绑到一个列表上。一个订阅会在对局进行期间让面板保持最新,而它只投递本地玩家的名次。
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))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.
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 竞技场。
- 核心概念——每个模块页都默认你已经掌握的那套词汇。
Tanks 里的血包
之前: 一辆受了伤的坦克会一直带着伤,直到它死掉。没有回头路,所以每场战斗都是倒计时,而竞技场也没有理由让人穿行其中。
之后: 血包出现在竞技场各处,彼此拉开距离,也远离正在交火的人。开过去就会给你回血。游戏的其他一切都不变——这个 Room 的代码也不变,因为它根本就没有代码。
这是第二课 Tanks。它需要三步,不需要新模块:一份给血包的 Declaration、一份关于血包在哪里出现的 Declaration,以及一个决定捡起它会发生什么的 Hook。在 Getting Started 之后上这一课,和 Tanks 里的 Leaderboard 的先后顺序随意。
第 1 步——声明这个血包
一个血包是一个 Entity,应用了两个 preset,并带一个只报告接触、不拦住任何人的 body。pickups 层上的 Response.Pass 正是让它成为拾取物而不是障碍物的原因:接触被报告出来,而运动径直穿过去。
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)])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
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,而这一步决定了这个功能玩起来公不公平。间距让血包不至于扎堆,与玩家的距离让它们不会刷进一场单挑里,而不重复规则让同一个点位不至于每次都是标准答案。
[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)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
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,而它是这一课里唯一的代码。它作为云函数在平台上运行,这也是它在两个引擎页签上都不出现的原因。
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 示例。
每日锦标赛
你会得到什么:一个带报名窗口、已布种的 Rooms 和奖励发放的每日锦标赛——完全由你已经有对应页面的那些模块上的 Declarations 和 Hooks 搭出来。这里没有一样是新概念;它就是 Leaderboards、Matchmaking、Rooms、Commerce 和 Messaging 为一个大循环(meta)组合到了一起。
第 1 步——声明这块榜,带上报名窗口和次数上限
一场锦标赛就是一份普通的 Leaderboards Declaration 加上参与约束:一个报名窗口、一个参赛人数上限、以及每周期的尝试次数上限。计分方面什么都不变——排序键、聚合方式和重置跟任何一块榜上完全一样。
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)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
[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 为这场对局布种——就是每场对局都会走的那条“分配加座位”的路径,只不过被限定在这场锦标赛的队列里。
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)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.
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)是最后一个还拿着这场对局最终状态运行的东西,而它就在那里提交。
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 参数带进模板。
[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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 步原样不动。
接下来去哪儿
- Leaderboards——锦标赛的那些轴(报名窗口、最大参赛人数、尝试次数)。
- Matchmaking → Rooms——队伍、分配和布种。
- Extensibility → Catalog & Commerce → Messaging——发奖的那条 Hook 链。
- 核心概念——每个模块页都默认你已经掌握的那套词汇。
核心概念
其余各页在使用时不会停下来解释的那些词。 一个模块页默认你已经知道什么是 Actor、什么是切面、什么是 Room——在这里,它们每一个都得到一行定义,外加一个通往“它背后的机制真正住在哪里”的链接。在读模块参考之前把它读一遍,或者等某个词的分量超出你预期时再回来。
有三样东西太大了,装不进一个词条,各占一页:权威性——是谁在调用,以及单凭这一点就决定了什么;SDK 是怎么建起来的——SDK 由什么构成;继承与组合——模块之间如何相互构建。按这个顺序,它们读起来是一条完整的论证。
四个接口面
每个模块正好暴露四样东西,而每个模块页都围绕它们来组织。这就是编程模型:
| 接口面 | 含义 |
|---|---|
| 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 是对称的;角色解锁得少一些 |
mc | Room 主机接口面:一个 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。
权威性
权威性是一种抽象,不是 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。
Access & Roles
角色是组合出来的,从不硬编码。 原子权限组合成角色;角色把数据一直管到行和列,并决定一个构建到底能看见哪些模块接口。它取代了客户端/服务端的密钥划分:一份凭据点名一个身份,它的角色按每次请求解析。
何时使用
- 你需要一份比“客户端”或“服务端”更窄的凭据——组合出来的角色在它背后按每次请求解析。
- 数据访问必须停在行和列上:区域限定、PII 掩码、只读的外包人员。
- 一个构建应该只看到它的角色所解锁的那些接口——对一名访客来说,踢人/关闭压根就不存在。
- 你的 UI 必须诚实地把按钮置灰——
CanI求值的正是服务器将要执行的那份策略。 - 不必用它:交付的那些预设(
player、room-owner、seller……)已经和你的 Actors 对得上时——每个模块默认都尊重它们;完整目录在核心概念。
谁做什么
| Actor | 在本页 |
|---|---|
operator | 声明角色和策略,设定行/列上限,授予角色,签发密钥 |
match-organizer | 下面这个流程里的赛事工作人员:持有一份组合出来的密钥,把关报名,但不能退款 |
every actor | 动手之前先查 CanI;只看得到自己解锁的那些接口 |
一览
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()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 解锁了哪些接口 |
谁来发放一个角色。 给一名玩家授予和撤销一个角色,以及这个 Project 为新玩家声明的默认角色,都是 Auth & Players 上的 operations——那个模块拥有身份,而一个角色是由凭据里的身份解析出来的。本页拥有一个角色是什么;那一页拥有把它交出去这件事。
错误
- 被谓词藏起来的东西回答
not found,而不是forbidden——否则这次拒绝本身就告诉了调用方那样东西存在,而那恰恰是藏起来要防的。 - 调用方不持有的权利回答
forbidden,前提是那个对象的存在本身不是秘密;而且它会点名缺的是什么,而不是干巴巴地失败。 - 掩码之外的字段在答案里是缺席的,而不是在场且为空:一个空值和一个被掩掉的值将无从分辨。
- 一个角色直接或者经由一条链包含了它自己,是配置错误——在声明层面就被拒绝,而不是留到运行时去解。
- 委托绝不拓宽能力:一次这个 Actor 以自己名义做不了的调用,以一名玩家的名义去做同样会被拒绝。
限制
每一条上限都点名它在边界处的行为;数字会随平台限制那一章一起落地。
- 在一个行谓词之下一次选择的规模是有界的,而这个 ACL 模型会把那个界声明出来,而不是等着去发现它。超过上限的一次读取,得到的是上限那么多行,外加一个说明它被截断了的标记,绝不会是一页悄无声息地变短的结果。
- 一份已解析权限的陈旧上界是声明出来的,而一次撤销不会等它走完——它立刻让其失效。
用户流程
一个赛事组织者的密钥,从角色组合一直到一次实时的权限变更。
SDK 是怎么建起来的
有两个问题常常被人搞混,而两个都有简短的答案。SDK 是怎么写出来的——为什么同一个想法在 Python 里和在 Unreal C++ 里看上去略有不同。SDK 是怎么运行的——在你的调用和线路之间垫着什么。本页把两个都回答一次,好让任何模块页都不必再答。
从一般写到特殊
这个 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。
- 模块靠组合而不靠继承。 具体怎么组合,以及“继承”在这里诚实地讲究竟意味着什么,都在继承与组合。
线程、生命周期与测试
循环归你所有。我们只往恰好一个地方投递,而且绝不背着你来。 这个 SDK 不会起任何需要你知道的线程,不会递给你任何锁,并且只从你在启动时选定的那一个上下文调用你的代码。从你喜欢的任何线程调用我们;我们只从一个线程调用你。
一个投递上下文,而循环归你所有
一个实例声明恰好一个投递上下文——它所有处理器运行的那唯一一个地方。它在你初始化时定下,并且在这个实例的整个生命周期里不会改变。一个 Event、一次数据 Delta、一次调用的结果:它们全都到达那里,别处都不到。
它有两种形态,你在启动时挑一种:
- 由你来泵。 运行时自己什么都不做;你从你自己的循环里把待投递的东西抽干。这是引擎想要的形态——投递落在游戏线程上,落在你选定的一帧里。
- 由我们持有。 运行时持有一条专用的执行线程。这是一台游戏主机或者一台专用服务器想要的形态。
两者谁也不是谁的兜底,而且不存在牵涉线程池的第三种选项。承诺只有一个上下文,重点就在于你永远不必去问我们开了几条线程。
上下文从来不是一个参数。 没有哪个处理器接收“我现在在哪条线程上”这样的参数,也没有什么可以去查询。你的处理器在哪里运行,是契约的一项属性,而不是这次调用的数据。
启动和停止都是显式的
初始化是一次由你发起的调用,而它以一个结果作答。没有任何东西会在首次使用时惰性初始化——那是被禁止的,而不只是不推荐;理由值得写一句:惰性启动会把“一个被禁用的模块在哪里可见”这唯一的一个地方,挪到碰巧最先发生的那次任意调用上,在那里它读起来就像是那次调用失败了。
一个被禁用的模块在启动时就被点名,而那个结果会说明发生的是两件事中的哪一件:整条依赖链都关掉了,或者你在少几样东西的情况下运行,外加一份用不了的清单。不存在悄无声息的第三种情况。
// 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, idempotentAttribute-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.
// 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 是合法的,而且不会死锁。不过它的结果绝不会在那同一个处理器里面到达——它会作为同一个上下文上的一次独立投递回来。往里走是允许的;在里面掉头则不行。
阻塞投递上下文是被禁止的,而这条禁令不是建议。等网络、等别人的锁、同步地等你自己那次调用:在处理器里面全都被禁止。这条禁令有一个症状——一个把上下文占住、超过它所声明预算的处理器,要么产生一次被声明的投递降级,要么产生一次被声明的拒绝。它绝不会产生的,是一次让你在某位玩家的会话里才发现的、悄无声息的变慢。
// 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 downAttribute-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.
// 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 说的地方,所以不会因为你在它旁边装了什么,别处就冒出或者消失了什么。
如果一个可选单元被引用了却加载不了,那是初始化的一个已声明结果——跟一个被禁用的模块被上报的是同一个地方。绝不会是一个悄无声息什么都不做的桩。
每一种绑定都声明它所针对构建的最低运行时版本。低于它,你在初始化时就会得到一次拒绝,而不是部分可用:否则一个太老的运行时会在它所缺的第一项能力上崩掉,而那个位置在你代码里是任意的,并且通常是在某位玩家的机器上,而不是你的。抬高那个下限是一次破坏性变更,走的流程和其他任何一次一样。
接下来去哪儿
- Getting Started——第一个 Room,从头到尾。
- SDK 是怎么建起来的——为什么只有一套接口面,以及各个部件是怎么拼上的。
- 深入内部——这一层底下的那一层,如果你好奇的话。
Core
Core 就是你唯一创建的那个对象,其他一切都挂在它上面。 一个 key 进去,你就拿到了上下文、身份、类型化的失败、追踪和批处理。每一次模块调用都要经过它,而没有哪个模块会自带一份自己的版本。
何时使用
- 你需要知道你是谁、在哪里——身份、角色、已解锁的模块、Project、env、region,全在你手上那一个对象上。
- 一个云函数必须以某名玩家的身份写入——这次写入归属于那名玩家,而留下的记录里同时写着双方:函数和玩家。
- 重试绝不能重复生效——批量 operations 携带一个幂等键。
- 一次失败必须可分支、可搜索——每一次抛出都是一个带稳定代码的类型化
Problem。 - 不必用它:你要的是消息、调用或者状态时——那些是 Primitives:Events、RPC、Data & Subscriptions。
谁做什么
| Actor | 在本页 |
|---|---|
any actor | 通过 Whoami 读取身份、上下文和角色 |
backend-service | 以某名玩家的身份行事;把幂等的 operations 批起来 |
operator | 读取失败或被重试调用的追踪 |
一览
Whoami, the ambient context, and a batch that retries safelyvar 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")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.
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 | 它以一次拒绝作答,带着平台目录里的一个代码 |
local | SDK 在发出去之前就拒绝了,用的是它自己公布的那套词汇 |
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。挂载语义——命名空间、挂载时的冲突拒绝——住在深入内部。
错误
rate_limited carries the moment a retry is allowedtry { 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:
raiseAttribute-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.
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 读到的那条追踪。
Events
一个 Event 是“某件事发生了”这个事实,投递给所有应该听到它的人。 用它来表达那些只发生一次、并且无法从某个当前值补追回来的事——一次射击、一笔购买、一次进入 Room。
何时使用
- 某件事发生了,而别人必须对它作出反应——开了一枪、锁上一扇门、结束一场对局。
- 受众是变化的——同一次发出可以到达一个小队、一个 Room,或者一个 Actor,由类型所声明的目标决定。
- 你想要带自动补全的类型化处理器——一个已声明的 Event 会变成它接口面上的
send.和on.,各带各的契约。 - 这个事实一小时后还必须可读——把类型声明为 retained,然后按时段把它读回来。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 用 [Event] 声明 Events,推送 schema |
any actor | 通过 send. 发出,通过 on. 订阅 |
一览
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))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 来表达,绝不用发出时传进去的一个地址 |
clock | sim_time 或 timestamp,绝不两者兼有——sim_time 用于模拟内部的事实,它们会参与预测、延迟补偿和回溯;timestamp 用于模拟之外的事实,比如一笔购买或者一次登录 |
retention | transient——到达发出那一刻正在订阅的人,且不被存储;或者 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 上的一个槽位,而不是在发出时作的决定——所以同一个类型总是以同样的方式被保留,没有哪个调用方需要记住哪次调用是哪一种。
| Transient | Retained | |
|---|---|---|
| 到达 | 那一刻正在订阅的人 | 那些人,外加之后才来的订阅者 |
| 之后 | 没了 | 保留一段已声明的期限 |
| 可读回 | 否 | 是,在这段期限内 |
| 超出期限之后 | — | 一次选择会被拒绝,而不是答以空 |
[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)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 一直到每个小队成员在自己屏幕上看到的那个标记。
RPC
一次类型化的调用,而它的方法体住在别处。 RPC 是第二个 Primitive:把这个过程声明在它该在的地方——一个模块上,或者一个 Entity 里面——然后每一种绑定都会得到一个生成的、可 await 的方法。动词是 invoke:单向是 Declaration 点名的一种模式,不是第二个动词,而且并不存在 do。
何时使用
- 调用方需要一个答案——请求/响应,带类型化的返回值。
- 调用方上报完就走人——一个已声明的单向 RPC,什么都不往回传。
- 这件工作比这次调用活得更久——一个已声明的延迟 RPC 交还一个工作描述符,而不是一次超时。
- 一个问题,多个回答者——一次 Group 调用就是 N 次调用,而每个答案到达时都绑着发出它的那个成员。
- 这个动词属于某样东西——就把它声明在那个 Entity 里面;一个 Entity 的 RPC 别处不存在(Entity 展示了这份 Declaration)。
- 不必用它:没有人被要求去做什么的时候——一个别人只是对之作出反应的事实,是一个 Events。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 声明 RPCs、它们的模式,以及谁可以调用它们 |
any actor | 在 Declaration 允许的地方,发起一次有回复的或单向的调用 |
group member | 回答一次扇出调用;每个成员往回传一个答案 |
一览
[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)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 mode | with a reply——一个已声明输出类型的值,或者一次类型化的拒绝;或者 one-way——没有回复,调用方只会知道本地的发送失败。当调用方需要那个结果时,绝不能用单向:一个未知的结果比一次已知的拒绝代价更大 |
execution mode | immediate——结果在这次调用之内返回;或者 deferred——这次调用返回一个工作描述符,而结果靠读取或者靠订阅到达。这是声明出来的,绝不由实现根据负载来挑,因为调用方是按回复的形状来搭它的行为的 |
streaming | 输入和输出是否分批到达,并在到达时逐批处理,而不是整体处理 |
idempotency | 一个单向 RPC 同样携带一个幂等键:没有回复不等于没有重复投递 |
overridability | 声明在方法本身上。没有声明就意味着不可覆盖——绝不会默认可覆盖 |
context | 它被声明在哪里。一个声明在 Entity 里面的 RPC 是那个 Entity 的一部分,在它之外不存在。在游戏服务器里声明一个就是把它注册进路由器——不存在第二种添加方式 |
一次调用携带什么。
| 携带 | 是什么 |
|---|---|
arguments | 只有调用方必须选定的那些 |
implicit context | 接收方、调用方和环境上下文,在你写的第一个参数之前就已绑定——一个 Entity 的方法绝不会被要求提供那个 Entity 的标识符 |
references | 一个本身是 SDK 对象的参数,以一个类型化的 Ref 传输——一个标识符或者一个游标,绝不是它内容的副本。接收方以它自己的名义去解析它,受同样的权限和谓词约束:一个引用是一个地址,不是一次被授予的权限 |
outcome | 一个已声明输出类型的值,或者一个类型化的 Problem |
一次延迟调用的描述符持有什么。
| 持有 | 是什么 |
|---|---|
state | accepted → 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。
[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 ranexport 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 ranAttribute-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 的并发延迟调用数——新的那个被拒绝,在途的那些跑完。
- 描述符寿命——过了它,结果就不再可得,而那是一次拒绝。
- 调用链深度——超出时是一次已声明的拒绝,绝不会是资源耗尽或者悄无声息地断掉。
用户流程
一次分数提交、一次延迟上报,以及一次询问某个小队准备好了没有。
Data & Subscriptions
你改一个字段。下游的一切都在不写一行代码的情况下发生。 Data 是第三个 Primitive:垫在每个同步字段底下的那套机制——相对上一次已确认状态的 Deltas、作为策略单位的切面、优先级和发送速率、可续接的订阅、保留窗口,以及变更前/变更后的 Hooks。
你面向的是 Entities,不是表——读取和修改的接口面(查找、过滤、排序、分页、订阅一个选择集)参见 Entity;本页讲的是底下的机制。不存在通往一张表的消费方路径,也不存在第二种写入方式:一次变更是一个 Entity operation,而 Delta 是随之而来的东西。
何时使用
- 你需要把状态复制到客户端,却不想写快照代码——改一个字段就是全部的同步。
- 各个字段在紧急程度或受众上不同——按切面设优先级和发送速率上限,再加一个用于战争迷雾的可见性谓词。
- 一个重连的客户端绝不能悄无声息地跑偏——空档会被检测到并被点名,而超出保留窗口的空档会以完整状态作答。
- 你需要最近的过去——那个按
sim_time索引的 Deltas 保留窗口,正是预测和延迟补偿所读取的东西。 - 一条校验规则该待在一个地方——一个变更前 Hook 在变更落地之前把它夹住或否决掉。
- 不必碰这些旋钮:你只需要读取或查询时——Entity 的那套接口面骑在这些机制之上,却不必碰它们。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 声明切面、它们的同步策略,以及可见性谓词 |
any actor | 订阅一个目标;从一个位置续接;请求完整状态 |
backend-service | 变更前/变更后 Hooks |
operator | 读取按 Actor 计的分包开销;看到投递何时降级、或者一个包何时被截断 |
一览
tank: motion at 30 sends a second, loadout only for its ownerpublic 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 consequenceexport 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 consequenceclass 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 consequenceAttribute-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
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 mode | shared packet——给所有人同样的东西,省 CPU;或者 per-actor packet——按各自的可见区域各发各的,费 CPU,而且在人数大时是必需的 |
visibility rule | 决定到底谁会收到的那个谓词——Visibility 把那一半完整地投影出来 |
一个订阅持有什么。
| 持有 | 是什么 |
|---|---|
target | 一个实例、一个选择集,或者一个切面,而它接收那个目标的 Deltas。一个目标不是一条流:一个目标可能覆盖许多二元组,而顺序是在一个二元组内部承诺的,不是跨整个目标 |
position | 它从哪里续接:由消费方出示。如果这个空档大于保留窗口,到来的就是完整状态,而不是一串 Deltas,所以一次长时间断连绝不会让客户端悄无声息地错着 |
state | active → gap detected → resynchronised | closed,而 closed 是终态 |
每一条流都成立的事。
| 总是成立 | 是什么 |
|---|---|
merging | Deltas 允许合并:两次发送之间的 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 可以做什么,以及在什么时候。
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 计的包的开销——用尽时,按声明降级到共享包。
用户流程
一次位置变更,从那次赋值一直到每块屏幕上被纠正过的运动。
Groups
一份名单,一个群体监听者。 一个 Group 是第四个 Primitive:一组被命名的 Actors,作为一个整体来接收。你面向这个 Group 说话,每个成员都听得到——一个 Room、一个聊天、一个 matchmaking 池和一份发送名单,是同一个 Primitive 在不同规则下的样子:不同的进出逻辑、不同的生命周期,底下是同一份名单。
何时使用
- 你需要队伍、小队或者公会——一组被命名的玩家,带一个已声明的容量,以及在类型声明了的地方,一个生命周期。
- 成员关系应该跟着一条由平台求值的已声明规则走——新晋老兵自动落进来,不需要一个定时任务,也不需要你自己去调一次重新求值。
- 你想一次对许多玩家说话:一个已声明的 Event 用
send.*扇出去,一个已声明的 RPC 到达每个成员,而每个答案回来时都带着名字。 - 你需要把一个成员关系模型复用成一份受众——一个 Visibility 作用域、一场 Messaging 会话、一支 Matchmaking 队伍。
- 不必创建一个,当这组人就是一场会话的成员时——Rooms 就是这个 Primitive 配上 Room 规则,而且已经把那些人寻址好了。
谁做什么
| Actor | 在本页 |
|---|---|
player | 从已声明的类型创建 Groups,加入和离开,添加或移除成员,发送 Events,发起扇出 RPCs;在持有某个 Group 成员关系的管理权时,移除成员并关闭它 |
room-owner | Room 的座位规则骑在这个 Primitive 之上(在 Rooms 里配置) |
backend-service | 声明 Group 类型和它们的规则;在进入和退出时挂 Hooks |
一览
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 groupAttribute-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 的名单就永远不可能对不上。
[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: ...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() };
[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 mode | explicit——一个成员靠一次动作被添加和移除;或者 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 是一个群体监听者 |
states | created → 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 按成员各带回一个答案,然后这支小队作为一个整体去排队。
Extensibility
平台的每个场景都是一串已注册的函数。替换其中一环,或者把它包起来。 这就是“可定制的平台”具体意味着什么,也是它用来代替开源的东西:你用你自己的步骤替换掉平台自己的步骤,所以你不需要我们的源码。
何时使用
- 某个平台步骤必须跑你的逻辑——用
[Override(…)]为一个被命名的环节声明替代实现。 - 你需要在一个步骤前后做检查或者产生副作用——有顺序的
Before/After中间件,可以否决或者通知。 - 代码必须按排程、按 Event 或者按 webhook 运行——触发器递给你一个已解析、类型化的上下文。
- 你必须在部署之前就知道实际会跑什么——对一条链做一次演练,读出解析后的顺序。
- 不必用它:这条规则只关乎一个 Entity 的写入时——一个 Data & Subscriptions Hook 是更轻的形式。
谁做什么
| Actor | 在本页 |
|---|---|
backend-service | 覆盖环节,用中间件把步骤包起来,写触发器处理函数 |
operator | 检视链条,设定顺序,读取 secrets,对解析结果做演练 |
一览
SignIn, wrap grant with middleware, run code on a cron// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
模型
平台给你什么东西去挂靠。
| 术语 | 是什么 |
|---|---|
registered function | 一个可覆盖的平台步骤——“创建档案”“解析价格” |
scenario | 一个平台流程所跑的那条有序的链:认证、加入、购买 |
overridability | 一个环节是可替换、只可包裹,还是固定不动 |
middleware | 环绕一个环节的、有顺序的前置/后置处理器 |
trigger | 启动你代码的东西:一个 Event、一个排程、一个 webhook |
secret | 你的处理函数可以读取的一个值 |
invocation | 一次运行,连同它的追踪 |
一个 Hook 声明什么。
| 声明 | 是什么 |
|---|---|
position | 它所挂靠的那个被命名的步骤 |
kind | gatekeeper——一次准入检查或者一次校验,而它失败时关闭,所以当这个 Hook 自己坏掉时,那个步骤不会运行;或者 observer——一条日志、一次通知、一个计数器,而它失败时放行,步骤照跑,而这次失败仍然会被上报而不是被吞掉。没有默认值 |
moment | before——在校验之前,接收类型化的载荷,可以修改或者拒绝;或者 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”的意思 |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
Overrides, middleware and triggers all execute as cloud functions on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
拿走它,改掉它,发出去。“专有的开源”是一套工作流,不是一句口号——平台自己的逻辑就是你可以拉下来、编辑、再部署的函数:
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 从它自己那一侧画出同一次购买。
一条带覆盖和中间件的链,可以在任何东西运行之前就被解析出来并读一遍。 解析后的顺序在面板里和从代码里都可以检视。
课程与实战:Tanks 里的 Leaderboard 用到了本模块的 Hooks;每日锦标赛从头到尾贯穿本模块。
Schema as Code
在代码里声明这个模型,把它推上去,拿回类型。 这是开发者进入 schema 的方式:管理面板和代码写的是同一个模型,而 codegen 为每个引擎把这个环闭上。
何时使用
- 你的数据模型应该住在代码里,并且像代码一样被评审——声明、
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 密钥下运行:合并时推送,随后重新生成引擎类型 |
一览
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 = 0Attribute-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
[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 mode | seed——代码在记录不存在时创建它,而重复推送不会动那些值,所以之后归管理控制台所有;或者 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。数字本身随那一章一起落地。
用户流程
一个新字段,从它在代码里的声明一直到重新生成的引擎类型。
Entity
其余一切所倚靠的那个模块。 一个 Entity 是一份 schema Declaration,长出了各种实时切面:数据 0..*、状态 0..*、RPC 0..*、Events 0..*、Hooks,以及变更历史。 地图把障碍物绑到 Entities 上,碰撞绑一个变换切面,Stats 就是一个 preset,而世界物体是一个 preset 加一个状态机。
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、读取状态 |
一览
[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()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();
};
[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.Inventory | Inventory 为那名玩家提供的句柄,只要那个模块被挂载,处处可用 |
playserv push 才是让一份 Declaration 变真的东西。 Schema as Code 拥有这一步:它把你的声明对着已部署的模型做 diff,带上它做 diff 时所对照的那个 revision,而如果已部署的 schema 动过了,它会拒绝而不是覆盖。一次会破坏已经在线实例的再推送,要走 propose → plan → apply,所以在任何东西改变之前,这份计划是可读的。
在客户端这一侧,这个 Entity 就是 API:
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"))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.
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 的目标——这个实例——所以订阅了这扇门的每个人都听得到,而发送时不带任何接收者名单。
模型
一辆坦克、一扇门、一条属性条和一个任务全都是 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 | 订阅一个选择集会让它保持实时,成员随着它们的数据变化而进出 |
// 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()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.
// 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:
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})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.
// 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 一直到玩家听到的那声铃响。
继承与组合
模块之间相互构建,而其中没有一处是子类化。 没有可以派生的基础模块,也没有可以扩展的层级——模块构成一张图。本页讲的是“继承”在这里诚实地讲究竟意味着什么,以及真正在干活的那六种机制。
继承在这里意味着什么
这个词涵盖四种不同的机制,而把它们分别叫出名字是值得的。
- 一个 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。
Entity presets
一个 preset 是一组被命名的 Entity 切面——数据、状态、RPC、Events、Hooks——为某一个游戏场景打好了包。你可以应用一个 preset、调它的数字,或者派生你自己的。应用一个 preset 是往你的类型上添加切面;它不会把你的类型塞到任何东西下面——一个 preset 不是一个模块,也没有任何属于它自己的东西可供继承。Stats、abilities、projectiles、掉落表和世界物体是五个 presets,不是五个子系统:同一份 Declaration、同一套同步、同一个 Hook 顺序。
何时使用
- 你游戏里的某样东西带着一些会被夹住、会再生、并且在触到边界时触发一次转移的数字。
- 一个动作需要消耗、冷却、阶段和效果,而且要能从一个客户端动词就够得着。
- 有东西飞出去了,而它的命中必须对一名有延迟的射击者公平地判定。
- 战利品必须来自加权概率,并且在玩家对一次掉落提出争议时能被精确重放。
- 地图上有家具——门、按钮、陷阱、可破坏物——它们的状态必须挺过一次中途加入。
- 不必用 presets,当一个 Entity 就是普通的同步数据时。把字段声明出来,到此为止。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 声明 stats、abilities、projectiles、掉落表、世界物体 |
room-owner | 调 preset 的数字,摇掉落表,创建世界物体 |
player | 施放 abilities,开枪,捡战利品,跟物体互动 |
一览
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})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.
// 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);模型
一个 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——而它们没有一个是你需要挂载的模块。
Rooms
一个 Room 是一场游戏会话;平台不在乎是什么在托管它。 一个抽象覆盖了每场对局一台专用服务器、一张被切成若干逻辑层的大共享地图、一个由 master-client 托管的 Room,以及一个后端托管的小游戏。Room 的内部实现是我们的;你是从外部驱动一个 Room 的。
何时使用
- 你的游戏有会话——对局、大厅、地下城、竞速——而必须有什么东西拥有它们的生命周期、成员关系和重连。
- 你托管在专用服务器上、某名玩家的 master-client 上、或者后端自己身上,并且需要把玩家路由过去。
- 一张共享地图必须跑许多逻辑会话——层,由 Visibility 限定作用域。
- 玩家在会话中途加入,并且必须看到当前的真相——到达时这个 Room 的状态,然后是实时流量。
- 一次掉线不能让人丢掉座位——模板的宽限窗口(
battle里是 45 秒)会恢复同一份成员关系。 - 不必用它:一个功能纯粹是基于记录的请求/响应时——普通的 Data & Subscriptions 已经覆盖了它。
谁做什么
| Actor | 在本页 |
|---|---|
room-owner | 在整个进程范围内注册 Rooms;在一个 Room 实例上——那套按实例的管理接口:热改配置、踢人、上锁、广播、销毁 |
entry-validator | 用一个代码和一个原因接受或拒绝加入请求 |
room-visitor | 浏览,带数据加入,在宽限窗口内重连,离开 |
spectator | 加入但不参与对抗;接收广播和实时流量 |
match-organizer | 预留会计入容量的座位;一个预留在模板所定的时限(battle 里是 90 秒)到期 |
一览
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 longAttribute-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
[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 都完整持有它——注册、每个进程托管好几个、热改配置、踢人、发布、销毁。
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()Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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.
客户端:
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))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.
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 mode | our 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 | 一次重连是从会话状态续上的;这个模块不承诺那段空档里的 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 踢人、上锁、热改配置、关闭、销毁——它是对那一个实例持有管理或主机角色的人开放的,而不是对成员关系开放的 |
两种权威模式。
| 模式 | 谁在跑 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。
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()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.
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 reservations | Matchmaking 按模板所定的预留时限——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 显示出谁加入了。
谁看到什么,哪台机器在跑
两个听起来像一个的问题。《谁看到什么》说的是客户端:这个 Room 的状态里,哪一片会到达哪名玩家。《哪台机器在跑》说的是宿主:哪个进程拥有一个 Entity,以及接下来由哪个拥有。把两者搅在一起的那个词是 replication:在游戏引擎里它通常指第一个问题,而在这里它指第二个。
| 你想问的是 | 就去读 |
|---|---|
| 哪个客户端收到哪份状态,以及收到多少 | Visibility,连同 Data 和 Prediction |
| 哪台机器拥有这个 Entity,以及它死掉时会怎样 | What Survives Losing a Host |
它们声明在两个不同的地方
两者都不是在运行时配置的,而且它们不共用一份 Declaration。
| 声明在 | 它点名的是 | |
|---|---|---|
| 谁看到什么 | aspect 上——Data、Visibility | 可见性谓词、物体上限及其顺序、哪些相邻区域可见,以及投递模式 |
| 哪台机器在跑 | Room 类型上——Rooms | 权威模式、对一个外部权威所报结果的信任程度,以及宿主掉线时的行为 |
它们还有一处不同:什么都不说时会发生什么。一个没有自己可见性规则的 aspect 会走共享包投递,那是默认值,对一个小 Room 来说也是对的。而一个没有点名权威模式的 Room 类型会被拒绝:这里没有默认值,因为没有任何东西能替你在我们的模拟和一个外部模拟之间做选择。
Visibility
40 名玩家时,整个 Room 的快照没问题。200 名时就不行了。 一个可见性区域决定谁收到什么,它是一个已声明的谓词,而不是你逐个物体去拨的开关。广播和按 Actor 分包是同一个模型的两种已声明投递模式,所以在两者之间切换是配置,而不是重写。它是一项通道优化,而不是一项权限——那件事参见 Access & Roles。
同一个已声明的模型——谓词、层、细节层级——可以按两种方式来读。在两者之间切换是配置,不是重写,因为两者都是对同一份 Declaration 的读法。
| 广播 | 按 Actor 分包 | |
|---|---|---|
| 发送 | 整个 Room,发给所有人 | 每名玩家只收到他们的规则所选中的那一片 |
| 适用于 | 小 Room;这是默认 | 人群,那里包大小必须保持可预测 |
| 读这份 Declaration | 一次,为整个 Room | 按 Actor 各读一次 |
绝不能泄露的东西是不在包里,而不是在客户端被藏起来——从不发出去,这让它成为一项安全属性,而不是一项带宽属性。
何时使用
- 你的 Rooms 长到超出了整房广播——200 名玩家需要的是按客户端的邻域流,而不是每一个 Delta。
- 状态绝不能泄露:战争迷雾和只有所有者可见的字段应该是从不发送,而不是在客户端藏起来。
- 好几场会话共享同一张地图,而且彼此不能看见——一个层就是多一个谓词。
- 在人群里包大小必须可预测——给物体数量封顶并声明顺序,这样“最近的 N 个”才是一项承诺,而不是密度碰巧的结果。
- 一名站在边界上的玩家必须能看到界外——把哪些相邻区域可见声明出来,因为默认只有他们自己所在的那个,否则一道边界读起来就是一堵空白之墙。
- 不必用它:这个 Room 很小的时候——共享包这种投递模式已经够了。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 声明可见性谓词、物体上限和它的顺序、哪些相邻区域可见,以及投递模式 |
any | 订阅并接收这个区域所准许的东西;可以在已声明的范围之内为自己调低物体上限 |
一览
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 scopeAttribute-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
[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 mode | shared 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 计的分包开销是
fnadm——一个云函数或者面板,绝不是一个客户端在问被人看着要花多少钱。
限制
每一条上限都点名它在边界处的行为;它们背后的数字会随平台限制那一章一起落地。
- 一个按 Actor 计的包的开销——用尽时,带通知地按声明降级到共享包,绝不会随意丢掉接收者。
- 每条规则的物体数——带一个已声明顺序和一个可观察截断标记地封顶。
- 每个 Actor 的订阅数——新的那个被拒绝,已有的那些继续。
- Delta 大小——这个 Delta 会被拆开而不是截断,而且这次拆分是可观察的。
- 发送速率——一个上界,不是一项保证。
用户流程
一条半径规则把一个 200 人的 Room 变成按客户端的邻域流。
“谁看得到这个?”和“他们看到什么?”两个都可以查询,因为答不上来的那次调试才是昂贵的那次。按 Actor 计的分包开销是一等的读取项,代码里和面板里都是。
丢掉一台主机之后什么还在
一台主机在对局中途挂了。对局没有。 本页讲的是“replication”这个词的第二重含义——哪台机器拥有一个 Entity,以及下一台拥有它的机器是谁。第一重含义,也就是哪个客户端接收哪份状态,在 Visibility,配合 Data & Subscriptions 和 Prediction & Lag Comp。谁看到什么,哪台机器在跑是把这两者分辨开的地方。
Room 状态不会在主机之间复制
一个 Entity 在同一时刻恰好有一个所有者,也没有第二台机器保留一份随时可以接手的活副本。
两份副本同时接受同一发子弹,就必须就这两发子弹落地的顺序达成一致。每秒三十次地在多台机器之间就一个顺序达成一致,那叫共识——而共识把延迟放在了游戏最不能容忍的地方。单一的所有者没有这个问题,而下面每一套机制的存在,都是为了让单一所有者变得可幸存,而不是为了绕开它。
真正被复制的是在场状态:哪个 Actor 在哪个节点上。那是一个很小、变化很慢的事实,所以路由可以到处都知道它,而不必为任何会动的东西付出达成一致的代价。
被声明的状态保存在宿主之外
被声明的状态并不为持有它的那个进程所私有。它按已声明的间隔被快照,所以当前一台主机不再作答时,一台接替者可以从最后一份快照恢复,而玩家经由普通的 Rooms 宽限窗口重新进入。
由此有三件事,而这就是它诚实的形状:
- 接替者拿到的状态是完整的,但只是快照那一刻的。 是完整,不是最新。一次故障转移的代价,是最后一份快照到这次损失之间的那段游戏内容,而那个间隔就是把这个最坏情况定下来的东西。
- Tick 的连续性不会跨越权威的变更被带过去。 一次由平台执行的迁移会保住参与者的 tick 状态;而权威的一次更替不承诺这一点。Rooms 是这两者被声明的地方,连同宽限窗口走完之后会发生什么。
- 任何你只保存在引擎 actor 里的东西,都随进程一起消失。 它从来没有被声明过,所以那台主机之外从来没有任何东西拥有过它。
一次部署走的是同一条路,只是没有损失
排空一台主机——不再往那里放新 Room,让在途的会话跑完或者交接出去,然后放它走——就是有意地、带着预告去跑一遍故障转移路径。这就是为什么“部署时不杀掉在线会话”不是第二套需要建起来并让人信任的机制:它就是这一套,只不过是有意启动的,而不是被一次崩溃启动的。
这个 Room 的主机得知此事的方式,跟它得知任何事的方式一样:平台会提前通知它,某个 Room 将因为平台自己那一侧的某个原因被关闭或者交接出去。
窗口走完之后会发生什么是声明出来的,而且没有默认值。 一个权威活在平台之外的 Room 类型,会为丢掉那个权威点名三种结果之一——等完一个已声明的窗口、关闭这个 Room,或者准许一个接替的权威进来。不说清楚不是这份 Declaration 提供的选项,因为另一种情况正是它存在所要防的那种失败:一个权威已死的 Room 仍然接受加入、仍然占着座位,向每一个参与者展示一场什么都不会发生的实时会话。
哪台机器不属于你的接口面
你从不点名一个节点。创建一个 Room 的人不选它在哪里运行,而且没有任何 operation 把主机当作一个参数——放置是平台的事,而且会一直是平台的事,好让它可以挪动一个 Room,而不必顾虑你的代码是照着它原先在哪里写的。
如果你自己托管 Rooms——一台专用服务器或者一个 master client——上面这些同样成立,只多一条:你会被告知要收摊,而在宽限窗口之内把你的那些会话跑完或者交接出去,是你的事。Rooms 是一台主机为那种绑定去注册的地方,而权威性解释了为什么这台主机只持有被授予的那些权利。
Matchmaking
把一名玩家送进对的那个 Room。 Tickets 描述这名玩家,并筛选其他人。Matchmaker 解出一次分配、预留一个座位,之后游戏流量就直接流向那个 Room。
Matchmaker 只一次出现在路径上,用来决定你归属何处。它不在对局的路径上:它的结果是一次分配和一个有时限的座位预留,而从加入那一刻起,游戏流量径直走向那个 Rooms。所以一个繁忙的队列绝不会变成一场繁忙的对局。
何时使用
- 你需要按已声明的标准——模式、区域、段位——把玩家路由进 Rooms,而不是手搓一份大厅列表。
- 撮合标准必须来自平台数据,而不是客户端的主张:在入队前 Hook 里把段位盖上去。
- 队列应该在服务端随时间放宽,而客户端只持有一张 ticket,从不轮询。
- 队伍必须一起落进同一场对局——一个 Group 要么整体进去,要么完全不进。
- 你在跑一个外部 matchmaker,只需要把它的决定落成分配 + 座位预留。
- 不必用它:玩家自己挑会话时——Rooms 的浏览器和
Join已经够了。
谁做什么
| Actor | 在本页 |
|---|---|
player | 创建和取消自己的 ticket,并作为一支队伍的一部分进入 |
match-organizer | 声明 matchmaker 队列和放宽规则;读取分配结果 |
backend-service | 在入队前盖上可信的标准;执行外部 matchmaker 的决定 |
一览
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)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.
// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);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"),
]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
[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 里盖上去:
[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 tAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 |
outcome | RoomPlacement——一个 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 有一个所有者:没有会话,就没有人可以入队。
Map
静态的世界:边界、地形、障碍物,以及“东西能去哪儿?”。 物理模型被有意做得比视觉模型简单得多:带一个占地轮廓和一个高度的基元、带规则的层,以及一个其他每个模块都复用的有效位置查询。
何时使用
- 你需要一个服务器可以查询、而不只是渲染的静态世界——边界、地形、障碍物。
- 出生点、掉落和装饰必须落在合法的点位上:一个基于规则的
RandomPosition查询,没有旁路。 - 竞技场应该每场对局重新生成——一个已声明的
Seed在一份 bug 报告里能复现出同一张地图。 - 箱子和墙会被打碎又回来——带 HP 和重生计时器的可破坏物。
- Bots 和 Entity Presets 需要针对障碍物集合的射线检测和视线判定答案。
- 不必用它:这个世界纯粹是视觉的、没有任何服务端代码去问“东西能去哪儿”时。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 声明地图、障碍物基元、可破坏物、层和它们的规则 |
room-owner | 把一张地图绑到一个 Room 上;请求出生位置;做射线检测 |
operator | 从面板里放置或移除障碍物和层 |
一览
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 assetAttribute-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
[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 assetScatter 和 Destructible 是放置生成器,不是运行时的摇号。一个生成器在地图版本发布时就解出结果:那四十块石头变成四十个已声明的基元,而发布出去的版本携带的是这些基元,不是那条规则。因此同一个 Seed 在对局里、在录像回放里和在 bug 报告里,给出的是同样那四十块石头——而几何限制只在这个解出来的集合上检查一次,在这个版本抵达某个 Environment 之前。
其他一切都会问的那个查询:
RandomPosition: a fair spawn on ground, away from players, never repeatingvar 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),
))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.
var spawn = map.RandomPosition(r =>
{
r.Layer("ground");
r.AwayFrom(players, minDistance: 12);
r.NoRepeat(lastN: 3);
});模型
两个层,由不同的人来声明。
| 层 | 它装什么,以及谁来声明它 |
|---|---|
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,不是一个你要挂载的模块。
Collision
把一个变换绑到障碍物地图上;声明接触会做什么。 碰撞在平台的模拟内部运行。你声明 body、层和响应,然后订阅接触。
何时使用
- 移动中的 Entities 必须在服务端解算接触——滑动、停下、弹开——而不必手写一套偏转例程。
- 玩法要对触碰作出反应:拾取物在重叠时被收走,触发体积点燃一个 Entity 状态机。
- Locomotion 和 Entity Presets 需要针对 Map 障碍物集合的扫掠解算。
- 放置预览或者瞄准需要“这东西放这儿装得下吗?”和体积重叠查询。
- 不必用它:没有任何东西在物理上相遇时——基于记录的请求/响应玩法就是普通的 Data & Subscriptions。
谁做什么
| Actor | 在本页 |
|---|---|
room-owner | 声明 body、层和响应;查询重叠和接触 |
这适用于哪些 Room。 这个模块运行在平台推进模拟的地方——声明了 Host = "Backend" 的 Room。如果拥有模拟的是你自己的 game server(把 PlayServ 当作元服务器),那么移动、碰撞和预测都留在引擎一侧,而这一页描述的是平台托管的那个替代方案,不是一项要求。
一览
形状、层,以及接触会做什么,全都坐在这个 body 自己身上——没有任何东西从远处去声明层的配对:
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
])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
[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 checked | stepwise——检查这一步的最终位置,快,而且一个快速的 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,不是你要挂载的模块。
Locomotion
你声明一样东西怎么动;没有人去写积分器。 一个运动模型把带序号的输入变成权威的运动,与 Collision 一起积分,为 Prediction & Lag Comp 记录下来,并被增益、减益和地形修饰。
何时使用
- Entities 在玩家输入下移动——坦克、角色、载具——而运动必须是服务端权威的。
- 你宁愿声明速度、加速度和转向速率上限,也不想写一个积分器。
- 玩法会把 body 推来推去:
Impulse击退、Teleport,以及带持续时间的泥地类修饰量。 - 移动必须感觉起来是即时的:同一个已声明的模型既在服务器上推进,也在 Prediction & Lag Comp 的循环里推进。
- 不必用它:位置只按离散步骤变化时——Entity 上的一个同步字段已经够了。
谁做什么
| Actor | 在本页 |
|---|---|
schema-author | 声明运动模型、约束和绑定 |
room-owner | 从主机一侧施加冲量、传送和修饰量 |
player | 提交带序号的输入;读取运动状态 |
这适用于哪些 Room。 这个模块运行在平台推进模拟的地方——声明了 Host = "Backend" 的 Room。如果拥有模拟的是你自己的 game server(把 PlayServ 当作元服务器),那么移动、碰撞和预测都留在引擎一侧,而这一页描述的是平台托管的那个替代方案,不是一项要求。
一览
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)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
[Entity("tank")]
public class Tank
{
[Sync] public Vector3 Position;
[Motion(Model.Tank, MaxSpeed = 8f, Acceleration = 14f, TurnRateDeg = 120f)]
public Motion Motion;
}客户端输入是一个带序号的意图。平台来推进这个运动:
Motion.Drive sent at input rate, stepped server-sideroom.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rateroom.my<Tank>().motion.drive({ throttle: 1, steer: -0.4 }); // cl — sent at input rateroom.my(Tank).motion.drive(throttle=1.0, steer=-0.4) # cl — a bot brain drives the same wayAttribute-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.
room.My<Tank>().Motion.Drive(throttle: 1f, steer: -0.4f); // cl — sent at input rate服务端一侧的动词:
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)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.
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 input | stop、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,不是你要挂载的模块。
Prediction & Lag Comp
玩家 50 毫秒前按下了跳跃。数据包刚刚才到。他们没有掉下去。 在携带真实事件时间的数据之上做向前预测和向后补偿:客户端感觉是即时的,服务器保持正确,而命中在射击者的时间线里被判定。
何时使用
- 在延迟之下输入必须感觉即时,同时服务器保持权威——向前预测,在分歧处和解。
- 命中必须在射击者的时间线里被判定:
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 只是把它们提前施加、或者倒着读回来;它从不声明第二份副本。
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 predictedAttribute-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
[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
}带延迟补偿的解算回答的是“这一枪开出去的时候大家都在哪儿”:
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 interpolatedexport 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 interpolatedAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 的障碍物集合外推出来的,所以两边从同样的输入画出同一条弧线:
Trajectory call: a collision-aware forecast the server and the aim preview sharevar arc = room.Prediction.Trajectory(from, velocity, steps: 30); // collision-awareconst arc = room.prediction.trajectory(from, velocity, { steps: 30 }); // collision-awarearc = room.prediction.trajectory(origin, velocity, steps=30) # collision-awareAttribute-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.
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,不是你要挂载的模块。
预测你自己的移动
这是客户端自己的机器,也是三种预测机制里唯一一个犯错很便宜的。 你在服务器还没答复之前就按你自己的输入行动,服务器答复了,而在两者不一致的地方,你的客户端纠正自己。这里犯一次错的代价是一次小小的视觉纠正——这也正是为什么在这件事上激进是安全的。
预测是把已声明的规则再跑一遍,不是第二份副本
你的客户端并不跑一份你那套移动的平行实现。它跑的是同一个已声明的模型,也就是平台跑的那个——模型属于 Locomotion,而预测只是把它提前施加。这就是两边大多数时候能对上的全部原因:只有一套规则,被施加了两次。
这也意味着没有什么“预测”operation 可以调用,也没有什么“纠正”operation。预测之所以发生,是因为那个切面被声明为可预测,而不是因为你调用了什么东西。
什么会预测是按切面声明的
可预测性是 Entity 切面上的一份 Declaration,而它被有意地不做成一个全局开关:
- 一个客户端算得出来的切面——你自己输入之下的位置——可以被预测。
- 一个权威按客户端并不拥有的规则去改动的切面绝不可以被预测。如果客户端推导不出它,那么去猜它就会产生一次回滚,而玩家会把那读成游戏在撒谎。
那条线就是你决定“什么可以闪烁、什么必须第一次就对”的地方。
纠正协议,以及塑造它的那两个数字
权威状态到达时携带着它所应用的最后一个输入的编号,所以你的客户端确切地知道自己缓冲区里还有多少是未确认的。从那里开始:
- 接受这份权威状态。
- 重放它所确认的那个输入之后缓冲下来的那些输入。
- 把结果与你已经在显示的内容和解起来。
有两个已声明的数字决定了这件事的手感。分歧阈值:低于它,这次纠正被平滑掉;高于它,你的客户端就会拽一下并重放。以及未确认输入缓冲区的上界:溢出不是未定义行为——降级是声明出来且可观察的,所以一个网络糟糕的客户端知道自己已经停止预测了,而不是默默地漂走。
分歧对发生了它的那个客户端是可观察的,而且只对那个客户端。 你可以知道自己的预测被纠正过,以及被纠正了多少——这对调参有用,对给玩家显示一个诚实的连接指示器也有用。你读不到别人的分歧:一次错误预测的大小是关于他们连接状况的信息,不是关于这个游戏的。纠正是客户端一侧的事,因为权威的状态本来就是其他所有人一直在看的东西。
如果你是从别的地方过来的
- Unreal 的 Mover 2.0。 这个形状很眼熟:带 tick 戳的输入、一个运动模型、来自权威的纠正。区别在于模型住在哪里——在这里你声明它,然后由平台去模拟它,所以没有一个属于我们的移动组件供你去子类化或者替换。
- 回滚重放式网络代码,比如 Photon Fusion 里的。 在一次纠正之后重放你自己那些未确认的输入,是同一套机制,而且在这里是完整的。被有意排除在外的,是事后把整个世界再跑一遍——发生的是什么、以及为什么,参见延迟补偿。
本页不涵盖什么
其他玩家的 Entities 不是被预测出来的,而是被显示出来的——那是显示其他玩家。在射击者的时间线里判定一枪是一个服务器机制,住在延迟补偿。而当一个 Room 的权威模式是外部时,这三者一个都不适用:那时这个 Tick 属于跑它的那一方,预测也一样。
显示其他玩家
没有人预测其他玩家——他们是被显示出来的。 你按间隔收到他们的状态,而中间那段你得画点什么出来。这里犯错不会让任何人丢命;它的代价是一次看得见的抖动,这也正是为什么它有自己的 Declarations,而不是跟预测共用。
显示模式是声明出来的,不是猜出来的
对于不属于你的那些 Entities,这个 Room 会声明怎么填补已到达状态之间的空档:在你手上已有的状态之间插值,或者越过最新的那个往外推。那是 Entity 上的一份 Declaration,所以每个客户端上的答案都一样,不会随着渲染器是谁实现的而跑偏。
插值延迟同样是声明出来的。 要平滑地显示其他玩家,就意味着把他们显示得稍微迟一点,迟一个已声明的量。把这个数字点出来正是重点:一个没说清楚的延迟是一份你没法复现的 bug 报告,而一个说清楚的延迟是一个你可以照着自家品类去调的设计决定。
外推会停下,而不是编造
外推窗口是声明出来的,越过它,这个 Entity 就不再被显示为在移动,而不是靠猜继续往前走。无限外推会让玩家去打一个从来就不在那儿的目标,而玩家察觉不到——一次看得见的卡顿,才是能走出来的那种失败。
为什么这件事跟预测你自己的分开
三种预测机制有不同的权威和不同的失效模式,而三者共用一个词,意味着调其中一个会悄悄改掉另外两个。
这也正是为什么存在一个只带这一种机制、别的都不带的 observer preset:一名观战者没有自己的输入可以预测,所以给它配预测设置,等于是在配置一件它并不做的事。
延迟补偿
这是服务器的机制,也是三者里唯一一个犯错会要人命的。 当它判错一次,就会有一名玩家死得不公平——而且是偏袒了连接更差的那一方。本页上的一切都被这种不对称塑造着。
它回答的问题很窄:射击者当时到底看到了什么? 一个动作可以携带一个视角时间,也就是这个 Actor 行动时所看着的那个 tick,而平台会把目标在那个 tick 时的姿态恢复出来,好让这一枪按他们屏幕上当时的样子来判定。
视角时间是一项主张,不是事实
它来自客户端,所以它是调用方的一项断言,也被当作断言来对待。有两个后果:
- 补偿窗口是有界的,而在它之外平台会拒绝。 它不会好心地去外推。一次拒绝是一个你看得见的决定;一次悄无声息的外推是一个你看不见的决定。
- 读取一个目标过去的状态仍然遵守可见性。 询问一个历史 tick 不是绕开 Visibility 的办法——你当时看不到的,现在也读不到。
而“这一枪不算”是一个裁定,不是一个错误:一次成功的答复,带着机器可读的理由。你的代码问了一个合法的问题,得到了一个合法的“不”。
什么会回滚是声明出来的,而且不是全部
把所有东西都回滚听起来很一致,实际会产出双杀:两名玩家互相开枪,两人都被回溯到一个双方都还活着的时刻,两人都命中。而什么都不回滚,则等于取消了延迟补偿本身。两者之间的边界是一份已声明的清单,不是某个实现的直觉。
回溯本身属于这里,而不属于被回溯的那些模块。历史环恢复出争议 tick 时的那些姿态,然后再拿那些姿态去问 Collision 它平常那个重叠判定问题——碰撞自己不保留任何历史,它里面也没有任何东西知道什么叫视角 tick。而这个环本身就是 Entity 的历史轨道,不是第二个存储。
判定发生在过去;效果施加在当下
延迟补偿回答的是一个关于射击者视角那一刻的问题。而那些后果——伤害、死亡、奖励——施加在当前的状态上。视角那一刻和决定那一刻之间发生的事,既不会被取消,也不会被重算。
所以下面这件事是可观察的,而且是有意为之:一名玩家可以在已经被别人一发经过回溯判定的子弹打死之后,仍然把一枪打出去。取消这一点,就意味着要在一次并不承诺可重现性的回溯之上重放整个世界——那是在制造分歧,而不是在消除分歧。
服务端一侧的重新模拟被有意排除在范围之外。 要按一份新的真相去重算后果,需要一个固定的参照点,而浮点状态给不了我们。留下来的是这个模块所倚靠的一切:一个客户端重放它自己那些未确认的输入(预测你自己的移动),以及作为为一个决定去读过去的延迟补偿。实践中的“偏袒射击者”就是这么运作起来的。
如果你是从别的地方过来的
- 偏袒射击者的延迟补偿,就像大多数竞技射击游戏里交付的那样:同一套机制,而本页就是它。
- 完整的回滚式网络同步。 回溯在这里;而之后对整个世界的重放不在,上面那一段解释了原因。如果你的设计依赖于后果在事后被重算,那这条依赖是要早点跟我们提出来的东西,而不是到晚了才发现。
另外值得知道的
- 实现是可覆盖的。 如果你的游戏需要一条不同的补偿规则,你可以替换掉我们的,而这个替代实现会声明它遵守哪些 Declarations。
- 在预测和纠正这条路径上没有任何扩展点。 那些东西按 tick 速率运行,而那个循环里的一个 Hook 会是你负担不起的 Hook。
- 在外部权威之下这些都不适用。 延迟补偿是为我们的模拟所运行的那些 Rooms 存在的。当这个 Tick 属于一家工作室的游戏服务器或者一个 master-client 时,补偿就属于跑它的那一方——参见 Rooms 里的“谁在跑 Tick”。
Bots
一个 bot 作为一名普通玩家加入。只有大脑住在别处。 同样的会话、同样的入场校验、同样的规则、同样的 ACL。这个 Room 分辨不出差别,而这是设计使然,所以 bots 会真正跑一遍你的游戏规则,而反作弊永远不需要一条 bot 例外。
何时使用
- 你的大厅在非高峰时段需要填人——
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/真人的交接 |
一览
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)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.
[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 outvar 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 inputsconst 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 inputsbot = 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 inputsAttribute-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.
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 全程都按真实规则运行。
写一个大脑
一个大脑就是回答一个问题的普通代码:这个 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 推理过程的存放地。
Auth & Players
登录是一个可覆盖的步骤,不是一个黑盒。 提供方、会话、身份关联、封禁。这个流程里的每一个点——登录前和登录后、关联前和关联后、合并前和合并后、状态变更时——都是一个带已声明种类的已声明扩展点:一道可以拒绝这一步的门禁,或者一个拒绝不了的观察者。
何时使用
- 玩家必须登录——设备、邮箱、Apple、Google、Steam 或者自定义——而“首次登录即创建”是一个开关,不是第二条流程。
- 一个访客账号之后必须能升级——
Link把 Steam 加上去而进度分毫不失,而合并把两个账号调和成一名玩家。 - 策略必须跑在跳不过去的地方——一道登录前的区域门禁,一份在“创建了这名玩家的那次登录”之后发的新手礼包。
- 审核必须有牙齿——撤销会话、封停、设备封禁,外加一个每个在线系统同时都能听到的
bannedEvent。 - 已声明的上下文(区域、平台、构建)必须到达后面每一个 Hook,而不必每一个都重新去读一遍那名玩家才知道。
- 没有更轻的东西可以退而求其次——其他每一个模块都是通过这一个来点名它的调用方的,而只要其中任何一个还需要一个玩家 Actor,
auth就关不掉:模块配置器会拒绝,并点名那些依赖方。
谁做什么
| Actor | 在本页 |
|---|---|
player | 登录,关联或解绑身份,刷新,登出 |
moderator | 撤销会话;封禁、封停或恢复玩家 |
backend-service | 按区域把关登录;为一名新玩家播下最初的那些行;读取并撤销会话 |
一览
SignIn call per provider, create-on-first-sign-in as a flag; Link adds Steam// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities// client — one call per provider; create-on-first-sign-in is a flag
const session = await PlayServ.auth.signIn(Provider.Device, { create: true });
await PlayServ.auth.link(Provider.Steam); // one player, many identities# client — one call per provider; create-on-first-sign-in is a flag
session = await playserv.auth.sign_in(Provider.DEVICE, create=True)
await playserv.auth.link(Provider.STEAM) # one player, many identitiesAttribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — one call per provider; create-on-first-sign-in is a flag
Client->Auth->SignInWithProvider(FPSProviderId::Device, Credential,
TPSOnResult<FPSSession>::CreateWeakLambda(this, [this](const TPSResult<FPSSession>& Result)
{
if (!Result.HasValue()) { return; }
// one player, many identities — add Steam to the same account
Client->Auth->Providers->Link(FPSProviderId::Steam, SteamCredential);
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// client — one call per provider; create-on-first-sign-in is a flag
var session = await PlayServ.Auth.SignIn(Provider.Device, create: true);
await PlayServ.Auth.Link(Provider.Steam); // one player, many identities每一个点都在它被声明的地方定制;一个处理函数可以采取的那些形式收在 Extensibility:
[Before(Auth.SignIn)] // a gate: it may refuse, and it is fail-closed
public static Verdict GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
[After(Auth.SignIn, created: true)] // an observer: it watches, it cannot refuse
public static async Task GrantStarterPack(Player player)
{
await player.Inventory.Grant("chest.gold", count: 1);
}// a gate: it may refuse, and it is fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// an observer: it watches, it cannot refuse
export const grantStarterPack = after(Auth.signIn, { created: true },
async (player: Player) => {
await player.inventory.grant('chest.gold', { count: 1 });
});@before(auth.sign_in) # a gate: it may refuse, and it is fail-closed
def gate_region(a: SignInAttempt) -> Verdict:
if a.region == "sanctioned":
return Hook.reject(Problem.FORBIDDEN, "region not served")
return Hook.continue_(a)
@after(auth.sign_in, created=True) # an observer: it watches, it cannot refuse
async def grant_starter_pack(player: Player):
await player.inventory.grant("chest.gold", count=1)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
An override and a hook are both cloud functions: they execute on the platform, not in the engine. Write them in C#, TypeScript or Python — Unity subscribes to the resulting events.
被声明出来的那个形式,就是面板会渲染的东西:每一个点都显示它的处理函数、它们的种类,以及解析后的顺序。种类是有牙齿的那一部分:
| 种类 | 当处理函数自己失败时 | 拒绝时 |
|---|---|---|
| 一道门禁 | 这一步被拒绝——一次到不了的区域检查不是一次通过了的区域检查 | 一个来自平台目录的代码,外加一条给人看的原因。调用方按代码分支;那段原因文本可以随意改动,也可以被翻译 |
| 一个观察者 | 这一步保持已完成,所以一份没能发下去的新手礼包,代价是一个箱子,而不是那次登录 | 它无法拒绝 |
任何处理函数都不可以做的一件事,是决定是谁登录了。一道门禁只针对平台已经确立好的身份回答是或否;它不点名那名玩家,不发放身份,也不替代提供方的确认。那条线就是“可覆盖的登录”和“可跳过的登录”之间的区别。
模型
一名玩家是身份的承载者,不是你 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 而进度分毫不失。
Profile
一个 Profile 是一个视图,而平台几乎不拥有它的任何部分。 平台关于一名玩家所保存的,是 player_id 和它背后的系统档案——身份、会话、提供方关联,这些全都在 Auth & Players。一名玩家所拥有的一切,都是你自己的 Entity,归那名玩家所有。一个 Profile 就是你的 Project 声明的那一组 Entities,为一个所有者一趟读出来。
何时使用
- 一个界面需要一次调用就拿到某名玩家的那一片——已声明的这一组会扇出到他们拥有的那些 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-sideAttribute-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
[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。
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 leavesconst 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 leavesmine = 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 leavesAttribute-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.
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 的那些上限,而不声明它自己的。
用户流程
从大厅里的涂装一直到等级跳一格:一次服务端的写入抵达一块已订阅的屏幕,而那块屏幕不用再问一次。
Social
一个新概念,其余一切都是用你已经有的东西搭起来的。 两个 Actors 之间的一段关系,带一个属于它自己的状态和一个发起人——这就是这个模块添加的全部。一个氏族、一个公会或者一支小队,是一个覆上了一层关系的 Groups,不是第二种东西;而拉黑——好几个模块都需要它——住在这里,好让只有一个地方拥有它。
何时使用
- 玩家需要按名字找到彼此——好友、关注者、黑名单。
- 一个氏族或公会需要一扇门——来自这个 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 的提议速率——一次带时间的限流。
- 流里在场状态变化的速率——靠更新速率来约束,而不是靠丢弃变化。
- 被拒绝和已断开关系的保留期——按已声明的时段移除。
用户流程
Messaging
Rooms、Groups、玩家:聊天和通知共用一个寻址模型。 消息到达一场会话;而会话就是 Core 的 Channels,在上面加了历史、审核和带外投递。
何时使用
- 玩家要说话——Room 聊天、公会频道、私信——用的是你本来就有的那套寻址:Room、Groups、玩家。
- 离线玩家也必须听得到——模板化、可排程的通知通过推送做带外投递。
- 审核必须跑在投递之前——一个发送前 Hook 过滤或者拒绝,而禁言/拉黑在任何地方都由平台强制执行。
- 回归的玩家需要补课——
History(take: 50)在下次启动时把这场会话分页拉出来。 - 不必用它:载荷是游戏状态而不是对话时——Data & Subscriptions 里的同步字段和 Core 的 Channels 已经把那些扇出去了。
谁做什么
| Actor | 在本页 |
|---|---|
player | 发送和接收消息;读取历史;禁言或拉黑 |
moderator | 过滤、涂抹,以及封禁词条 |
backend-service | 发送或排程模板化通知 |
一览
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)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.
// 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")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.
[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 权威发出的,绝不从一个玩家会话发出:
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})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 的形式,跟别处同一份契约:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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,他收到一条推送,并在下次启动时从历史里把这条集结读出来。
Catalog & Commerce
物品、价格、钱包、商城、购买、权益。 在各平台允许的地方接入真实的商店(Stripe、App Store、Google Play、Steam、Xbox);可排程、可按受众投放的商城;以及一条每一步都可挂 Hook 的购买流程。
何时使用
- 你要卖东西——通过 Stripe、App Store、Google Play、Steam 或 Xbox 收真钱,或者收钱包里的货币。
- 商城必须按玩家解析——排程、受众和价格都在服务端算出来,绝不把资格判断的算术放在客户端。
- 定价规则该待在一个可测试的 Hook 里——折扣、改价和否决都跑在任何扣款之前。
- 收据必须防重放,而一次退款必须通过发放时用过的那些 Event 把权益撤销掉。
- 不必用它:东西从来不卖时——不过奖励仍然要通过 commerce 那唯一一个 origin 为
reward的Grant落地(Leaderboards 的周期宝箱就是那样到来的),所以即便一个没有商店的游戏,也保有一份单一的、可审计的发放账本。
谁做什么
| Actor | 在本页 |
|---|---|
player | 浏览商城,购买,管理钱包,兑换礼包码 |
seller | 配置 catalog、价格和商城排程 |
backend-service | 校验收据;通过购买 Hooks 改价或发放 |
一览
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"))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.
// 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"));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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 | 它是由人创作的内容,按一个键寻址,所以在代码里改名就是一次改名 |
kind | consumable——会被消耗掉;或者 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 | 一个谓词,而不是一份玩家名单——所以这份受众是一条持续为真的规则,而不是一张快照 |
一个玩家落不进其受众的商城,对那名玩家来说并不存在。
一份订单的状态。
| 从 | 到 |
|---|---|
created | awaiting payment |
awaiting payment | paid、declined、expired |
paid | granted |
paid 或 granted | refunded |
| 总是成立 | 是什么 |
|---|---|
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 里读到的那个首购漏斗。
Inventory
一切都在这里汇合。 开枪扣弹药,掉落落进它,abilities 检查它,移动被它修饰——一组自有的行,带着按增量变化的堆叠,以及一个边界行为由你来选的按所有者上限。
何时使用
- 玩家持有东西,而一次持有就是一行带所有者的记录——按所有者读取,按所有者封顶,而溢出行为是声明出来的,不是默认出来的。
- 一个数量在累加——一个堆叠按一个带幂等键的增量变化,所以一次被重试的扣减不会扣两次。
- 其他模块从同一组东西里花销——开枪扣弹药,掉落发放战利品,而购买以针对其权益的行的形式出现。
- 不必用它:这个数字不可被拥有时——hp、xp 和冷却属于 Stats。
谁做什么
| Actor | 在本页 |
|---|---|
player | 读取他们自己的持有物并从中花销 |
backend-service | 代表一名玩家发放、增减和撤销,并点名它所代表的那名玩家 |
一览
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)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,不是各自独立的模块。
Leaderboards
每一种玩法,都被系统化了。 这不是一份榜单类型的目录。而是一个模型,它的那些轴组合起来就能构成全部:每日排名、最快单圈榜、公会总分、赛季、锦标赛。
那个代码块这样读:谁在本页上行动(actors)、这个模块交给你什么(provides)、它站在哪些模块之上(builds-on),以及它挂在根的什么位置——mounts: root 意思是 playserv.Leaderboards,而不是另一个模块之下的某个命名空间(深入内部)。
何时使用
- 分数必须给玩家排名——每日排名、最快单圈榜、Groups 总分——作为一个已声明的模型,而不是一块榜一套系统。
- 你需要那些标准读法——前 N 名、我周围、一份点名的所有者列表——而不必额外建数据模型。
- 周期必须按排程关闭、归档(绝不删除),并带着最终那张表触发一个奖励 Hook。
- 可疑的分数绝不能进表——一个提交前 Hook 校验、封顶,或者以一个类型化的理由拒绝。
- 一场锦标赛就是同一块榜加上一个报名窗口、一个最大参赛人数和每周期的尝试次数。
- 不必用它:这个数字从来不在玩家之间比较时——一个个人计数器或者一个生涯总数就是普通的 Data & Subscriptions。这个模块给结果排序;它从不计算结果,也不跑任何淘汰赛程。
谁做什么
| Actor | 在本页 |
|---|---|
player | 读取前 N 名/我周围/自己的名次,订阅名次变化 |
backend-service | 提交成绩;在提交前 Hook 里纠正或者拒绝它们;在一个周期关闭时发放奖励 |
operator | 声明榜单;提前关闭一个周期,纠正记录(有审计),观察提交速率 |
一览
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 itAttribute-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
[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 就是这么说的:
Submit: the two ranked fields and the display field, from the function that owns the resultawait 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")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 点名的那些字段——一个未声明的字段会被拒绝,而不是被存下来。
每个游戏都需要的那些读法,以及让它们保持最新的那个订阅:
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 closesconst 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 closestop = 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 closesAttribute-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.
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 closesAroundMe("weekly-score", 5) 是一个按名次的窗口,不是一页:你上面五行、下面五行,加上你自己——十一行,在表到头的地方对称地裁剪,所以第 2 名拿到的是两侧都更短的窗口,而不是一个被平移过的窗口。Top 是分页的:它返回前 N 行加一个游标,而 after: 走完其余的。
ForOwners 就是好友榜的做法。平台不持有任何好友关系图;你把你游戏本来就有的那些所有者传进来——一个 Groups 的成员,或者一份来自你自己数据的 id 列表——而每一行回来时带的是它在完整表里的名次,不是在这份列表里的名次。
OnRankChanged 只投递本地玩家自己的名次,别的都不投:一块有五万名参赛者的榜,不会把每一次洗牌都推给每一个客户端。回调收到的是变了的那一行——名次、参与排名的字段、展示字段——而 Cancel() 结束这个订阅。名次本身是一张快照:相隔一秒的两次读取可能不同,因为提交还在陆续落地;不过你自己的提交对你自己的下一次读取总是可见的。
模型
一块榜声明什么。
| 轴 | 取值 | 你怎么设定它 |
|---|---|---|
| 所有者 | 玩家、Groups | Owner = Owner.Player——一块公会榜就是同一块榜换成 Owner.Group |
| 排序键 | 一个或多个已声明的字段,各自升序或降序 | [Rank(1, Sort.Descending)] int Score |
| 聚合 | set、best、increment、decrement | Agg = 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 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:事后触发,它否决不了,而它在那里失败也仍然让这个周期保持关闭 |
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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 这块榜的一周:服务端一侧的提交、一次“我周围”的读取、周一的关闭以及它的奖励。
Files & UGC
文件以分块的形式到达,并在到达时就被处理。 上传、资源和它们派生出来的变体,以及带审核路径的玩家生成内容。
何时使用
- 玩家或者服务要上传二进制块——分块、可续传的会话,带可读的按玩家配额。
- 处理必须在一次上传结束之前就开始——把这个文件当作一条流来读,一块接一块。
- 玩家做的内容需要一条审核路径——
SubmitUgc、一个队列、一个裁定、两端各一个 Hook。 - 一张母版图片必须服务许多平台——派生出各种变体(缩放、转码),并把原件保持为权威版本。
- 不必用它:小的结构化载荷——一个 Data & Subscriptions 记录字段不用上传会话就能带着它们。
谁做什么
| Actor | 在本页 |
|---|---|
player | 上传分块,读取流式文件,提交 UGC |
moderator | 审阅队列,通过或驳回提交 |
backend-service | 派生资源变体;在上传和审核上挂 Hooks;设定配额 |
一览
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)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.
// 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 bindingvar submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clAttribute-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.
var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // cl围绕它的那些门禁是 Hooks,跟别处同一份契约:
[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)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
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.
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 的下架。
用户流程
一个玩家搭出来的关卡,从第一个上传的分块一直到通过的裁定。
Analytics
一切需要事后去数、而不是当下去看的东西。 声明一个类型化的遥测 Event,发出它,它就会落在平台自己那些旁边——一个关卡通关、一个漏斗步骤、一次经济事件、会话时长、新手教程里的一次流失。这个模块发出;它不读取、不聚合,自己也不把任何东西送到任何地方去——一个批次往哪个方向走,是路由器的事,在 Extensibility。
何时使用
- 某样东西必须事后被数——一个漏斗步骤、一个关卡通关、一次经济事件、会话时长。
- 这个比较必须挺过游戏构建的更迭——一个类型携带一个 schema 版本,所以一个一年前的漏斗不会悄无声息地变成同一个字段两种不同含义的拼接。
- 量很大,而只要你说了,丢一行是可以接受的——遥测是这份契约里唯一一个已声明的丢失是合法的地方。
- 不必用它:有人必须作出反应时——一个遥测 Event 根本没有任何订阅者;一个别人必须听到的事实是一个游戏 Event。
谁做什么
| Actor | 可以 | 不可以 |
|---|---|---|
any actor | 在 schema 里声明类型;以自己的名义发出,一次一个或者成批;读取那些已声明的类型 | 填写上下文;读取、查询或者聚合已经发出去的东西 |
backend-service | 同上,另加通过委托代表一名玩家发出 | 读取遥测——没有读取权限,因为根本就没有读取 operation |
一览
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))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.
[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,不是模块。
运营平面
被有意排除在 SDK 之外的那些东西。 Project 和 Environment 的生命周期、部署和回滚、计费、组织与用户管理、集群路由——这些属于管理面板、CLI 和 MCP 接口面,不属于游戏代码。唯一一处有意的例外是 Schema as Code:schema 是一个面向开发者的接口面,所以它在 SDK 里。
一个模型,两个平面
| SDK 平面 | 运营平面 | |
|---|---|---|
| 从哪里够到 | 游戏代码 | 控制面板、CLI、MCP |
| 持有 | Rooms、Entities、玩家、commerce、Leaderboards | Projects 与 Environments、部署和回滚、计费、组织与用户管理、集群路由 |
| 工作方式 | Declarations、Hooks、Events、Operations | 面板自己的那些界面 |
它们共享一个模型:你推上去的那份 Declaration,就是面板渲染出来的那一份。跨不过这条线的是权威——游戏代码不能部署、不能计费、也不能搬动一个租户。
每一个 SDK 接口面——每个模块,以及声明在 Entities 上的那些 presets——在控制面板里都有一个运营侧的对应物,同样那些 Declarations 在那里从另一侧被查看和编辑:
| SDK 接口面 | Operator 看到的 |
|---|---|
| Schema as Code / Data & Subscriptions | Entities、迁移、记录浏览器、保存的视图、导入/导出 |
| Entity | 状态机、按实例的检视 |
| Entity Presets | 掉落表,ability、Stat 和 projectile 的定义,世界物体 presets——可实时调参 |
| Access & Roles | 角色网格:角色 × operations、行过滤器、列掩码 |
| Extensibility | 带覆盖的场景链、解析后的顺序、调用追踪 |
| Rooms | 机队:Rooms、tick 健康度、放置、排空状态 |
| Matchmaking | 队列、在途的 tickets、放宽曲线 |
| Catalog & Commerce | catalog、商城排程、收据、退款 |
| Leaderboards | 周期、记录纠正(有审计)、提交速率 |
| Auth & Players | 提供方、会话、封禁、认证场景 |
| Files & UGC | 资源、UGC 审阅队列、配额 |
| Analytics | 仪表盘、转发器、摄取延迟 |
| Map | 地图和障碍物集合、在线实例 |
| Visibility / Collision / Locomotion / Prediction & Lag Comp | 按 Room 的调参:规则、响应配对、窗口、按 Actor 计的分包开销 |
| Groups / Messaging | Group 浏览器、模板、审核过滤器、排程 |
| Bots | 档案、填充配额、大脑端点 |
| Inventory / Profile | 持有物和转移、视图和自有 Entity 集合 |
这条设计规则。 一项在面板上没有对应接口面的 SDK 能力,对 live ops 来说是隐形的;一个没有 SDK 能力支撑的面板接口面,是一句谎话。模块两半一起交付,而一份 Declaration 不管在哪一边写下,在两边都是同一个模型。
一份 Declaration 的旅程:schema-author 写下它,playserv push 把它带上去,panel 为 operator 渲染它,而那次重新调参落在已经在跑的那些 Rooms 上。
智能体访问
面板所显示的一切,工具也都够得着:平台暴露了一个 MCP 接口面(就是面板所用的那同一套 API),所以 AI 智能体和脚本可以在跟其他任何 Actor 同一套访问模型之下去操作 Projects(bootstrap、schema、记录、玩家、部署)。
深入内部:传输与 hub
这是一份架构参考,不是一套你会去调用的接口面。本页上的任何东西都不出现在你面对的那套 API 里:没有 socket 要打开,没有通道要挑,没有信封要填,也没有重试要排。你的游戏代码永远不会碰上本页的这些机械——而这正是重点。 核心概念点出了这个栈;而机制只住在这里。它在这里,是为了让一位架构师能查清楚 SDK 拿一次掉线、一个被禁用的模块,或者一条必须恰好到达一次的消息,究竟是怎么办的。
层级栈
五层,自上而下:用户空间、模块、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 恰好走两条路中的一条:
- 把依赖链整个禁用掉。 每一个需要那个缺失模块的模块也一起关掉,而它的那些接口是缺席的,而不是会失败的。
- 声明功能降级。 那些依赖方保持挂载,并宣告它们不再能做什么。
不存在第三条路。悄无声息地半工作——一个已挂载的模块默默丢掉它不再能执行的那些 operations——正是这条规则存在所要防的那种失效模式,也正是为什么一个被禁用的依赖是可观察的,而不是神秘的。
为什么你不会碰上这里的任何东西
各个模块页上的每一项承诺,都是在这条线之上兑现的:一次 Entity 修改就是那次网络 operation,一个 Hook 就是一个类型化的函数,一次加入就是一次调用。这个栈的名字可能会传到你耳朵里——核心概念指向这里——但承诺的是你永远不必调用这里的任何东西,而不是这些词是秘密。下面那些层之所以存在,是为了让那些承诺能挺过一次传输的更换,而一页你永远不必读的文档,就是这件事奏效的度量。
你会碰上的东西——你的处理器所运行的那个投递上下文、一个句柄什么时候终结,以及你用来测试的那份内存实现——在上一页:线程、生命周期与测试。