Esta página ainda não foi traduzida: mostrando a versão em inglês.
Primeiros passos
Uma arena jogável (mapa, tanques, tiros, drops), declarada de ponta a ponta. Nada do que vem abaixo é um loop de jogo: a simulação roda dentro da plataforma.
Antes de começar
Você precisa de um projeto com um ambiente dev (criado no plano do operador, que cuida desse ciclo de vida), da CLI playserv autenticada nele e do pacote do SDK para o seu binding. Nada mais é instalado no seu jogo.
Cada chamada que você faz é um dos exemplos abaixo. Entre uma e outra, quem age é a plataforma, seguindo o que uma declaração diz. A habilidade (ability), o stat e a tabela de drops usados abaixo são presets de entidade: declarações feitas em entidades, não módulos próprios.
Declare o mundo
Uma entidade é o seu esquema mais os aspectos que funcionam em tempo real. Cada comportamento ganha um atributo, escrito ao lado do campo que ele descreve:
Tank: três políticas de sincronização e três aspectos de gameplay, uma linha cada[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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
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;
}Alterar um campo [Sync] já é a própria operação de rede. Não há snapshot para montar nem chamada de envio para fazer.
Nenhum desses tipos é você quem define, e cada um pertence a uma única página:
| No bloco | Vem de |
|---|---|
Vector3, Stat | o pacote principal do seu binding |
Body e as formas de corpo | Collision |
Motion e os cinco modelos de movimento | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | os presets de entidade que os usam |
EntryRequest, Verdict, StatEvent | payloads dos hooks, entregues pelo módulo em que o hook se encaixa |
Seat | Matchmaking |
Scope e os escopos de sincronização | Visibility |
Tick e as taxas de tick | Rooms |
Os enums são fechados. Uma regra que nenhum membro cobre vira um predicado, não um membro novo: [Aspect("loadout", Visible = "owner == caller.player")] é como se expressa visibilidade por campo quando Scope.Owner não é bem a regra que você queria (Data & Subscriptions).
Declare a sala
Um modelo de sala (room template) diz o que é uma sessão e nomeia as declarações de que ela depende. Não existe classe de sala (room) para herdar nem método de tick para preencher, porque o funcionamento interno da sala é assunto da plataforma:
battle e as três declarações que ele nomeia: uma arena, uma tabela de drops, uma arma[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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
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 ela nomeia |
|---|---|
rock | um objeto de cenário do conjunto de obstáculos do mapa (Map) |
ammo.shell, railgun | itens do catálogo (Catalog & Commerce) — é também assim que o tiro desconta munição e o item coletado vai parar no inventário |
battle, arena, crate-loot, shell | as chaves que essas quatro declarações registram |
playserv push recusa uma declaração cuja chave não existe no ambiente de destino. Assim, uma chave digitada errado falha no deploy, e não no primeiro disparo. Onde quer que o modelo esteja, ele continua ajustável sem um novo deploy do jogo: o modelo publicado é o que a equipe de live-ops edita no painel.
Escreva suas regras como hooks
Hooks são cloud functions que a plataforma chama em etapas com nome. Entrada tipada, saída tipada: nada de objeto de contexto genérico nem logger na assinatura da função.
[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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
Um hook é uma cloud function: ele roda na plataforma, não na engine. Escreva-o em C#, TypeScript ou Python — o Unreal assina os eventos que ele gera. Funções escritas na engine estão planejadas: grafos de Blueprint e C++ (traduzido para um alvo de script validado pela plataforma). As duas formas são analisadas e publicadas como qualquer declaração; a execução continua na plataforma.
Um hook é uma cloud function: ele roda na plataforma, não na engine. Escreva-o em C#, TypeScript ou Python — o Unity assina os eventos que ele gera.
Um Before funciona como barreira e pode recusar a etapa. Um observador After roda depois que a etapa foi confirmada e já não pode recusá-la. Por isso, um pacote inicial que não chegou custa 20 projéteis, e não o login. O resto está em Extensibility.
O que executa cada linha desse bloco:
| A linha | O que de fato a executa |
|---|---|
Stats.Depleted dispara | o stat chega ao mínimo — 0 para Hp, já que a declaração só definiu Max |
| a transição para a morte | AtMin = "death" na etapa 1; o hook acrescenta a consequência, não a transição |
| o dano | [Effect(Damage = 35)] no projétil, aplicado pela plataforma a cada acerto |
RollAt | gerado na declaração [DropTable] — por isso ela é partial, e por isso a aba Go usa drops.RollAtCrateLoot |
| a coleta | passar com o tanque por cima do loot transfere os itens para o inventário do jogador de forma atômica; essa transferência é o evento changed que o HUD exibe |
Tipos gerados para a engine
playserv schema codegen # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/GeneratedRode o comando (ou deixe a CI rodar) depois de cada publicação do esquema. Os tipos são regenerados, nunca editados à mão, e o Tank gerado é exatamente o Tank publicado. playserv push lê um projeto de engine do mesmo jeito que lê um projeto de servidor: os especificadores UHT e os atributos C# já são a declaração, então exportar se resume a apontar a CLI para o projeto UE ou Unity. Em qual thread um callback chega e quando uma assinatura (subscription) termina são definidos pelo modelo de runtime — Threads, ciclo de vida e testes.
Conecte um cliente
A API do cliente é simétrica: os módulos são os mesmos, e o que um build pode chamar depende da chave com que ele roda. Um build de engine leva uma chave de jogador (player key) — aqui, projectKey, a credencial de um projeto e de um ambiente, emitida no painel e distribuída dentro do build. Ela não nomeia nenhum papel (role): os papéis são resolvidos no servidor a cada requisição, e o jogador por trás deles chega com SignIn. Aqui o foco são os bindings de engine; os bindings de servidor usam a mesma interface 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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
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 a uma coisa diferente:
| Chamada | O que ela devolve |
|---|---|
Find | um ticket colocado e uma vaga (seat) reservada numa sala. A reserva vale pelo prazo que o modelo declara; deixá-la expirar custa a vaga, não o direito de jogar |
Join | resolve quando o estado atual da sala chega. O Tank que o modelo faz spawnar para cada membro que entra faz parte desse estado, então My<Tank>() já responde na linha seguinte, e tudo depois de Join é tráfego ao vivo |
Cast | atirar é o verbo das habilidades, não uma segunda interface: uma declaração [Projectile] é uma habilidade com balística por cima, então Cast verifica cooldown, munição e alvo do mesmo jeito que faria com um dash ou uma cura (Entity Presets) |
Abilities | gerado — a geração de código reúne as habilidades e os projéteis declarados em um único tipo por binding |
As recusas chegam como o Problem tipado da plataforma — um código e um motivo legível por humanos. C#, TypeScript, Python e Unreal lançam esse erro; Go o retorna como valor de erro, e é por isso que toda chamada naquela aba é verificada. Um servidor dedicado Unreal ou um cliente mestre roda o mesmo binário, só que com uma chave de host (host key), e os papéis dele trazem as linhas mc. Em Rooms, Hospedar uma sala mostra essa interface de ponta a ponta; Access & Roles é onde se declaram as chaves e os papéis por trás dela.
Publique e jogue
playserv push # schema + declarations + hooks, one deploy
playserv open battle # a dev-env room, live in the panelplayserv push varre o projeto em que roda — hooks com atributos e declarações — e faz o deploy no ambiente de destino (--env dev por padrão). playserv schema push sozinho publica só o modelo, e playserv schema diff mostra com o que uma publicação é comparada (Esquema como código).
Uma publicação entra inteira ou não entra, e é recusada, e não mesclada, se o esquema publicado mudou desde o seu diff. Levar um modelo de dev para prod é tarefa do plano do operador (operator plane), não uma chamada do SDK (o plano do operador).
Mudanças incompatíveis viram migrações
Uma mudança que quebraria dados existentes não vai junto com a publicação. Ela vira uma migração, que você lê primeiro e depois executa ou cancela (Esquema como código).
playserv open battle cria uma sala a partir do modelo battle publicado e a abre no painel, onde dá para inspecionar o estado e os membros da sala enquanto você joga nela. O painel agora mostra o modelo, o mapa, a tabela de drops e os hooks: o mesmo modelo que você escreveu no 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 balanceamento deste jogo, não tetos. Os limites da própria plataforma ficam acima deles, e cada um é declarado junto com o que quem chama vê ao atingi-lo:
| No limite | O que quem chama recebe |
|---|---|
| entrar numa sala lotada ou fechada | conflict — vale tentar de novo quando uma vaga liberar |
| criar salas além do limite por projeto ou por ator | recusa, e nada do que já foi criado é descartado |
| criar salas ou fazer login rápido demais | uma recusa por excesso de requisições, com o tempo de espera |
| um payload de evento acima do teto da sala | recusado antes do envio, nunca truncado |
| uma leitura além do teto de linhas de um papel | as linhas até o teto, mais o marcador que indica o corte |
Os valores em si variam por ambiente e chegam junto com os limites da plataforma; o comportamento no limite já vale e não depende deles (Rooms, Access & Roles, Auth & Players).
Próximos passos
- Exemplos, a seção logo depois desta: um ranking no Tanks, kits médicos no Tanks ou a receita do torneio diário para o loop de metajogo. Cada um traz uma funcionalidade real, e cada etapa leva à página do módulo responsável pelo que você acabou de usar.
- Como o SDK funciona, quando a estrutura dele começar a importar mais que a próxima funcionalidade: Conceitos principais é o dicionário, e quatro artigos respondem quem está chamando (Quem toma as decisões), como se escrevem as permissões concedidas (grant) (Access & Roles), do que o SDK é feito (Como o SDK é construído) e como ele é executado (Threads, ciclo de vida e testes).
- Depois, os módulos. Toda página de módulo segue a mesma estrutura, então cada uma se lê mais rápido que a anterior. Entity e Data & Subscriptions são as duas em que todo o resto se apoia.
Roteiros de leitura por especialidade
Qualquer que seja a sua especialidade, leia primeiro Quem toma as decisões, porque um único SDK com permissões concedidas por ator é o pré-requisito de todos, e deixe Conceitos principais aberto ao lado.
| Você é | Leia, nesta ordem |
|---|---|
| Desenvolvedor de cliente de jogo (Unity · cliente Unreal · TS) | Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, depois, conforme a funcionalidade: Inventory · Leaderboards · Messaging · Profile |
| Desenvolvedor de servidor (C# · TS · Python · Go) | Esquema como código → os blocos de construção → Entity → Extensibility → Access & Roles, depois os módulos cujas declarações ficam com você: Rooms · Matchmaking · Leaderboards · Catalog & Commerce |
| Desenvolvedor de servidor dedicado Unreal | Rooms (Hospedar uma sala) → Bots → Locomotion · objetos do mundo → Map → O que sobrevive à perda de um host |