Перейти к основному содержимому
С чего начать

Эта страница ещё не переведена — показана английская версия.

Первые шаги

Соберём играбельную арену — карту, танки, стрельбу и выпадающие предметы — целиком из объявлений. Игрового цикла ниже не будет: симуляция работает внутри платформы.

Что понадобится

Проект (project) с окружением dev — его создают на операторском уровне, который отвечает за жизненный цикл окружений (environment). CLI playserv, авторизованный в этом проекте. Пакет SDK для вашего биндинга. Больше в игру ничего устанавливать не нужно.

Все вызовы, которые делаете вы сами, показаны в примерах ниже. Всё, что происходит между ними, делает платформа — по тому, что написано в объявлениях. Способность, характеристика и таблица выпадения из этих примеров — пресеты сущностей: это объявления на сущностях, а не отдельные модули.

Объявите мир

Сущность (entity) — это часть вашей схемы вместе с её живыми аспектами. Каждое поведение задаётся одним атрибутом рядом с полем (field), которое оно описывает:

Сущность Tank: три правила синхронизации и три игровых аспекта, по строке на каждый
[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)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

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 — те же атрибуты компилируются в Unity, причём C# 12 здесь не нужен — всё собирается на базовой версии 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;
}

Изменить поле с [Sync] — значит отправить изменение по сети. Собирать снапшот и вызывать отправку не нужно.

Ни один из этих типов определять не нужно. Каждый описан на своей странице:

Тип в примереГде описан
Vector3, Statбазовый пакет биндинга
Body и формы телCollision
Motion и пять моделей движенияLocomotion
ObstacleSet, Drop, Flight, Ammo, Effectпресеты сущностей, в которых они используются
EntryRequest, Verdict, StatEventаргументы хуков: их передаёт модуль, в который вы встраиваетесь
SeatMatchmaking
Scope и области синхронизацииVisibility
Tick и частоты тикаRooms

Перечисления закрыты. Если нужного значения в них нет, правило записывают предикатом, а не новым значением. Например, если Scope.Owner — не совсем то, что вам нужно, видимость отдельного поля задаётся так: [Aspect("loadout", Visible = "owner == caller.player")] (подробнее — на странице Data & Subscriptions).

Объявите комнату

Шаблон комнаты (room template) описывает, какой будет сессия, и перечисляет объявления, на которые она опирается. Наследоваться от класса комнаты и писать метод тика не нужно: всё, что происходит внутри комнаты, делает платформа.

Шаблон battle и три объявления, на которые он ссылается: арена, таблица выпадения и оружие
[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)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

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 — те же четыре объявления компилируются в Unity; сборка Unity читает загруженный шаблон и подключается к комнатам по нему
[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ключи, под которыми регистрируются сами четыре объявления

Если ключа нет в целевом окружении, playserv push не примет объявление. Опечатка в ключе обнаружится при деплое, а не при первом выстреле. Где бы ни лежал шаблон, его можно перенастроить без повторной сборки игры: live-ops правит в панели именно загруженную модель.

Запишите правила в хуках

Хуки — это облачные функции, которые платформа вызывает на именованных шагах. На входе и на выходе у них конкретные типы, а в сигнатуре нет ни универсального контекста, ни логгера.

Три правила в хуках: не пускать забаненных игроков, выдать новичку 20 снарядов, разыграть добычу после смерти
[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)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unreal подпишется на события, которые он порождает. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unity подпишется на события, которые он порождает.

Хук Before может отклонить шаг. Хук After срабатывает, когда шаг уже завершён, и отменить его не может. Поэтому если стартовый набор не выдался, игрок (player) потеряет 20 снарядов, но войдёт в игру. Остальное — на странице Extensibility.

Что на самом деле стоит за каждой строкой этого примера:

СтрокаЧто за ней стоит
срабатывает Stats.Depletedхарактеристика (stat) опустилась до минимума: для Hp это 0, потому что в объявлении задан только Max
переход в состояние смертиAtMin = "death" из первого шага; хук добавляет только последствие, а не сам переход
урон[Effect(Damage = 35)] на снаряде; при попадании его применяет платформа
RollAtгенерируется для объявления [DropTable], поэтому класс помечен partial, а во вкладке Go вызов называется drops.RollAtCrateLoot
подборкогда танк проезжает по добыче, предметы атомарно переходят в инвентарь игрока; этот переход и есть событие (event) changed, которое отрисовывает HUD

Типы для движка

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

Запускайте генерацию после каждой загрузки схемы (schema) — вручную или в CI. Типы всегда генерируются заново и никогда не правятся руками, поэтому сгенерированный Tank в точности совпадает с загруженным. playserv push читает проект движка так же, как серверный: спецификаторы UHT и атрибуты C# сами по себе и есть объявление. Чтобы экспортировать модель, достаточно указать CLI на проект UE или Unity. В каком потоке придёт колбэк и когда закончится подписка (subscription), определяет модель выполнения — см. Потоки, время жизни и тесты.

Подключите клиент

Клиентский API симметричен серверному: модули те же, а что может вызывать сборка, определяет ключ, с которым она запущена. В сборку игры входит ключ игрока (player key) — здесь это projectKey. Он открывает доступ к одному проекту и одному окружению, выдаётся в панели и поставляется вместе со сборкой. Ролей (role) в самом ключе нет: сервер определяет их при каждом запросе, а игрок, от имени которого идёт запрос, появляется после SignIn. Основные здесь — биндинги для движков, но серверные биндинги могут работать с тем же API без интерфейса: так устроены мозг бота, нагрузочный тест или служебный инструмент.

Клиентская сессия: войти, найти матч, подключиться, следить за изменениями, ехать и стрелять
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)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

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 — работает в Unity без изменений, со сгенерированными типами
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создаёт один тикет и резервирует место (seat) в комнате. Резерв держится столько, сколько указано в шаблоне; если время вышло, игрок теряет место, но не право играть
Joinзавершается, когда пришло текущее состояние комнаты. Танк, которого шаблон создаёт для нового участника, входит в это состояние, поэтому My<Tank>() работает уже на следующей строке, а всё, что идёт после Join, — это живой трафик
Castвыстрел — это применение способности (ability), а не отдельный API. Объявление [Projectile] — та же способность, только с баллистикой, поэтому Cast проверяет перезарядку, боезапас и цель так же, как для рывка или лечения (Entity Presets)
Abilitiesгенерируется: кодогенерация собирает объявленные способности и снаряды в один тип для каждого биндинга

Отказ приходит как типизированный Problem: код ошибки и понятное человеку объяснение. В C#, TypeScript, Python и Unreal он выбрасывается как исключение, а в Go возвращается как значение ошибки — поэтому во вкладке Go проверяется каждый вызов. Выделенный сервер Unreal или мастер-клиент запускает тот же бинарник, но с ключом хоста (host key), и в его ролях есть строки mc. Всё, что с этим связано, описано в разделе «Хостинг комнаты» на странице Rooms, а ключи и роли объявляются на странице Access & Roles.

Загрузите и играйте

playserv push          # schema + declarations + hooks, one deploy
playserv open battle   # a dev-env room, live in the panel

playserv push находит в проекте хуки и объявления на атрибутах и деплоит их в выбранное окружение (по умолчанию --env dev). playserv schema push загружает только модель, а playserv schema diff показывает, с чем будет сравниваться загрузка (см. Схема в коде).

Загрузка применяется целиком или не применяется вовсе. Если развёрнутая схема успела измениться после вашего diff, загрузка будет отклонена, а не слита. Перенос модели из dev в prod делается на операторском уровне, а не вызовом SDK (см. The Operator Plane).

Несовместимые изменения становятся миграциями

Изменение, которое сломало бы существующие данные, не применяется при загрузке. Из него получается миграция: вы сначала её просматриваете, а потом запускаете или отменяете (см. Схема в коде).

playserv open battle создаёт комнату по загруженному шаблону battle и открывает её в панели. Там можно смотреть состояние комнаты и её участников прямо во время игры. Теперь в панели видны шаблон, карта (map), таблица выпадения (drop table) и хуки — та же модель, что вы написали в коде, и редактировать её можно и там.

Ограничения, которые задали не вы

Capacity = 8, Hz30, Hz = 10 и Cooldown = 1.5f — это настройки конкретной игры, а не предельные значения. У платформы свои лимиты, они выше, и для каждого описано, что увидит вызывающий код, когда в него упрётся:

СитуацияЧто получает вызывающий код
вход в заполненную или закрытую комнатуconflict — можно повторить, когда освободится место
создание комнаты сверх лимита на проект или на актораотказ; уже созданные комнаты не удаляются
слишком частое создание комнат или входотказ по частоте запросов с указанием, сколько подождать
событие с данными больше лимита комнатыотказ ещё до отправки; данные не обрезаются
чтение большего числа строк, чем разрешено ролистолько строк, сколько разрешено, и отметка, что результат обрезан

Конкретные числа задаются для каждого окружения и будут опубликованы вместе с лимитами платформы, а поведение на границе уже работает (см. Rooms, Access & Roles, Auth & Players).

Что дальше

  1. Примеры — следующий раздел: таблица лидеров в Tanks, аптечки в Tanks или рецепт ежедневного турнира для мета-игры. Каждый пример — одна настоящая фича, и каждый шаг ведёт на страницу модуля, которым вы только что воспользовались.
  2. Как работает SDK — когда устройство SDK станет важнее следующей фичи. Основные понятия — это словарь, а ещё четыре статьи отвечают на вопросы: кто вызывает (Кто принимает решения), как записываются права (Access & Roles), из чего состоит SDK (Как устроен SDK) и как он выполняется (Потоки, время жизни и тесты).
  3. Затем модули. Страницы всех модулей устроены одинаково, так что каждая следующая читается быстрее. Всё остальное опирается на две из них: Entity и Data & Subscriptions.

Что читать в зависимости от задачи

Кем бы вы ни были, начните со страницы Кто принимает решения: идея одного SDK, где у каждого актора свои права, нужна всем. Держите рядом открытыми Основные понятия.

ВыЧто читать, по порядку
Разработчик клиента игры (Unity · клиент Unreal · TS)Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, затем по нужным фичам: Inventory · Leaderboards · Messaging · Profile
Серверный разработчик (C# · TS · Python · Go)Схема в коде → строительные блоки → Entity → Extensibility → Access & Roles, затем модули, объявления которых вы ведёте: Rooms · Matchmaking · Leaderboards · Catalog & Commerce
Разработчик выделенного сервера на UnrealRooms (раздел «Хостинг комнаты») → Bots → Locomotion · объекты мира → Map → Что остаётся после потери хоста