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.
Before you start
You need 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.
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 used below are entity presets: declarations on entities, not modules of their own.
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).
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.
Write your rules as hooks
Hooks are cloud functions the platform calls at named steps. Typed in, typed out: no context bag or logger 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.
What performs each line of that block:
| 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/GeneratedRun 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.
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.
Push and play
playserv push # schema + declarations + hooks, one deploy
playserv open battle # a dev-env room, live in the panelplayserv 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. Moving a model from dev to prod is an operator-plane act rather than an SDK call (the operator plane).
Breaking changes become migrations
A change that would break existing data doesn't ride the push. It becomes a migration you read first and then run or cancel (Schema).
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 follows the same structure, so each one reads faster than the last. Entity and Data are the two everything else leans on.
Reading paths by role
Whatever your role, read Authority first, since one SDK with a grant per actor is the shared prerequisite, and keep 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 |