Skip to main content
Start here

Not translated yet — showing English.

Getting Started

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

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:

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

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

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

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

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

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

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

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

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

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

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

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

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

Declare the room

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

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

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

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

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

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

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

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

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

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

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

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

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

The quoted ids are content keys, not free text:

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

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

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.

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

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

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

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

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

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

@after(stats.depleted, stat="hp")
def on_death(e: StatEvent):
    CrateLoot.roll_at(e.entity.position)
Coming soon — Go

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

Runs off the engine

A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.

Runs off the engine

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

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

What performs each line of that block:

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

Generated types for the engine

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

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

Connect a client

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const FVector3f Aim(24.f, 0.f, 12.f);            // the world point under the crosshair
Room->Entities->Of<UTank>()->Select().GetMine().Then(
    TPSOnResult<UTank*>::CreateWeakLambda(this, [this, Aim](const TPSResult<UTank*>& MineResult)
    {
        if (!MineResult.HasValue()) { return; }
        UTank* MyTank = MineResult.Value();
        MyTank->Motion->SubmitInput(FPSMoveInput{ .Throttle = 1.f, .Steer = -0.4f }, InputSequence);
        MyTank->Call->Cast(PSKeys::Ability::Shell, Aim);
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
var playserv = await PlayServ.Connect(projectKey);
var session = await playserv.Auth.SignIn(Provider.Device, create: true);

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

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

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

Four calls, and each answers something different:

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

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

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

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

Where to go next

  1. Examples, the section straight after this one: a leaderboard in Tanks, health crates in Tanks, or the daily-tournament recipe for the meta loop — one real feature each, every step linking to the module page that owns what you just used.
  2. How the SDK works, when the shape of it starts to matter more than the next feature: Core Concepts is the dictionary, and four articles answer who is calling (Authority), how a grant is spelled (Access & Roles), what the SDK is made of (How the SDK Is Built) and how it is executed (Threads, Lifetime and Testing).
  3. Then the modules. Every module page 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 areRead, in order
Game client dev (Unity · Unreal client · TS)Auth → Matchmaking → Rooms → Entity → Data, then per feature: Inventory · Leaderboards · Messaging · Profile
Server dev (C# · TS · Python · Go)Schema → the building blocks → Entity → Extensibility → Access, then the modules whose declarations you own: Rooms · Matchmaking · Leaderboards · Commerce
Unreal dedicated-server devRooms (Hosting a room) → Bots → Locomotion · World Objects → Map → What Survives Losing a Host