Pular para o conteúdo principal
Comece aqui

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:

Declaração da entidade 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)
Em breve — Go

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
Unity C# is the same C# API here — os mesmos atributos compilam no Unity, e nada aqui exige C# 12 — tudo compila na versão de base 2021.3
[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 blocoVem de
Vector3, Stato pacote principal do seu binding
Body e as formas de corpoCollision
Motion e os cinco modelos de movimentoLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectos presets de entidade que os usam
EntryRequest, Verdict, StatEventpayloads dos hooks, entregues pelo módulo em que o hook se encaixa
SeatMatchmaking
Scope e os escopos de sincronizaçãoVisibility
Tick e as taxas de tickRooms

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:

O modelo 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)
Em breve — Go

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
Unity C# is the same C# API here — as mesmas quatro declarações compilam no Unity; um build Unity lê o modelo publicado e entra nas salas criadas a partir dele
[RoomTemplate("battle", Map = "arena")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
}
[Map("arena", Seed = 42, Bounds = "160x160")]
public static class Arena
{
    [Scatter("rock", Count = 40, MinSpacing = 6)] public static ObstacleSet Rocks;
}
[DropTable("crate-loot")]
public static partial class CrateLoot
{
    [Entry("ammo.shell", Weight = 60, Count = "2..4")] public static Drop AmmoShell;
    [Entry("railgun", Weight = 1)]                     public static Drop Railgun;  // the jackpot
}
[Projectile("shell", Cooldown = 1.5f)]
public static class Shell
{
    [Ballistics(Speed = 24, Gravity = 9.8f)] public static Flight Arc;
    [Ammo("ammo.shell", PerShot = 1)]        public static Ammo Load;
    [Effect(Damage = 35)]                    public static Effect OnHit;
}

Os ids entre aspas são chaves de conteúdo, não texto livre:

ChaveO que ela nomeia
rockum objeto de cenário do conjunto de obstáculos do mapa (Map)
ammo.shell, railgunitens 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, shellas 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.

Três regras como hooks: barrar jogadores banidos na entrada, dar 20 projéteis a um jogador novo, sortear o loot na morte
[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)
Em breve — Go

Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.

Executa fora da engine

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.

Executa fora da engine

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 linhaO que de fato a executa
Stats.Depleted disparao stat chega ao mínimo — 0 para Hp, já que a declaração só definiu Max
a transição para a morteAtMin = "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
RollAtgerado na declaração [DropTable] — por isso ela é partial, e por isso a aba Go usa drops.RollAtCrateLoot
a coletapassar 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/Generated

Rode 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):

A sessão do cliente: fazer login, encontrar uma partida, entrar, reagir a mudanças, dirigir e atirar
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)
Em breve — Go

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.
Unity C# is the same C# API here — roda sem alterações no Unity com os tipos gerados
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:

ChamadaO que ela devolve
Findum 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
Joinresolve 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
Castatirar é 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)
Abilitiesgerado — 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 panel

playserv 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 limiteO que quem chama recebe
entrar numa sala lotada ou fechadaconflict — vale tentar de novo quando uma vaga liberar
criar salas além do limite por projeto ou por atorrecusa, e nada do que já foi criado é descartado
criar salas ou fazer login rápido demaisuma recusa por excesso de requisições, com o tempo de espera
um payload de evento acima do teto da salarecusado antes do envio, nunca truncado
uma leitura além do teto de linhas de um papelas 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

  1. 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.
  2. 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).
  3. 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 UnrealRooms (Hospedar uma sala) → Bots → Locomotion · objetos do mundo → Map → O que sobrevive à perda de um host