Цю сторінку ще не перекладено — показано англійську версію.
Перші кроки
Зберемо арену, у яку вже можна грати: мапу, танки, стрільбу й предмети, що випадають, — усе з оголошень (declaration), від початку до кінця. Ігрового цикл у нижче не буде: симуляція працює всередині платформи.
Що знадобиться
Проєкт із середовищем (environment) dev — його створюють на операторському рівні, який керує життєвим циклом середовищ. 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, поки не вирішено (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[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 | аргументи хуків — їх передає модуль, до якого ви підключаєте хук |
Seat | Matchmaking |
Scope і області синхронізації | Visibility |
Tick і частоти тіку | Rooms |
Переліки закриті. Правило, якого не покриває жодне значення, записують предикатом, а не новим значенням. Наприклад, якщо Scope.Owner — не зовсім те правило, яке ви мали на увазі, видимість окремого поля задають так: [Aspect("loadout", Visible = "owner == caller.player")] (Data & Subscriptions).
Оголосіть кімнату
Шаблон кімнати (room template) описує сесію і перелічує оголошення, на які вона спирається. Успадковувати клас кімнати (room) і писати метод тіку не треба: усе, що відбувається всередині кімнати, — справа платформи:
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, поки не вирішено (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[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 редагує в панелі саме завантажену модель.
Запишіть правила як хуки
Хуки — це хмарні функції, які платформа викликає на іменованих кроках. На вході й на виході — конкретні типи, а в сигнатурі немає ні універсального об’єкта контексту, ні логера.
[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, поки не вирішено (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" із кроку 1; хук додає наслідок, а не сам перехід |
| шкода | [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Запускайте генерацію (або доручіть її CI) після кожного завантаження схеми. Типи не редагують вручну — їх щоразу генерують заново, тож згенерований Tank і є завантаженим Tank. playserv push читає проєкт рушія так само, як серверний: специфікатори UHT і атрибути C# самі й становлять оголошення, тож щоб експортувати модель, досить указати CLI на проєкт UE чи Unity. У якому потоці прийде колбек і коли завершиться підписка (subscription), визначає модель виконання — Потоки, час життя і тести.
Підключіть клієнт
Клієнтський API симетричний: модулі ті самі, а що може викликати збірка, визначає ключ, з яким вона працює. Збірка гри має ключ гравця (player key) — тут це projectKey, облікові дані для одного проєкту й одного середовища. Його видають у панелі й постачають усередині збірки. Ролей у ключі немає: сервер визначає їх під час кожного запиту, а гравець, від імені якого йде запит, з’являється після SignIn. Головні тут — біндинги для рушіїв, а серверні біндинги працюють із тим самим інтерфейсом у headless-режимі (мозок бота, навантажувальний тест, службовий інструмент):
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, поки не вирішено (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.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 | завершується, коли надійде поточний стан кімнати. Танк, якого шаблон створює для кожного нового учасника (member), входить у цей стан, тож My<Tank>() повертає його вже в наступному рядку, а все після Join — це живий трафік |
Cast | постріл — це дія здібності (ability), а не окремий інтерфейс: оголошення [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 panelplayserv push сканує проєкт, у якому його запустили, — хуки й оголошення на атрибутах — і деплоїть їх у вибране середовище (типово --env dev). playserv schema push завантажує лише модель, а playserv schema diff показує, з чим порівнюватимуть завантаження (Схема в коді).
Платформа застосовує завантаження цілком або не застосовує зовсім. Якщо розгорнута схема змінилася після вашого diff, платформа відхилить завантаження, а не зіллє зміни. Перенести модель із dev у prod можна на операторському рівні (operator plane), а не викликом SDK (The Operator Plane).
Несумісні зміни стають міграціями
Зміна, яка зламала б наявні дані, не проходить разом із завантаженням. Вона стає міграцією: ви спершу її переглядаєте, а потім запускаєте або скасовуєте (Схема в коді).
playserv open battle створює одну кімнату із завантаженого шаблону battle і відкриває її в панелі: там видно стан кімнати та її учасників, поки ви в ній граєте. Тепер у панелі є шаблон, мапа, таблиця випадання (drop table) і хуки — та сама модель, яку ви написали в коді, і там її теж можна редагувати.
Числа, які обирали не ви
Capacity = 8, Hz30, Hz = 10 і Cooldown = 1.5f — це налаштування саме цієї гри, а не стеля. Власні ліміти платформи вищі, і для кожного описано, що отримає код, який викликає, коли впреться в межу:
| На межі | Що отримує код, який викликає |
|---|---|
| вхід у заповнену або закриту кімнату | conflict — варто повторити, коли звільниться місце |
| створення кімнати понад ліміт на проєкт чи на актора | відмова; уже створені кімнати нікуди не зникають |
| надто часте створення кімнат або вхід | відмова через ліміт частоти із зазначенням, скільки чекати |
| корисне навантаження події, більше за ліміт кімнати | відмова ще до відправлення, без обрізання |
| читання понад стелю рядків, дозволену ролі | стільки рядків, скільки дозволяє стеля, і позначка, що результат обрізано |
Самі числа задають для кожного середовища окремо, і їх опублікують разом із лімітами платформи. Поведінка на межі на них не чекає (Rooms, Access & Roles, Auth & Players).
Що далі
- Приклади — наступний розділ: таблиця лідерів у Tanks, аптечки в Tanks або рецепт щоденного турніру для мета-циклу. Кожен приклад — одна справжня фіча, і кожен крок веде на сторінку модуля, яким ви щойно скористалися.
- Як працює SDK — коли будова SDK почне важити більше за наступну фічу. Основні поняття — це словник, а ще чотири статті відповідають на запитання хто викликає (Хто ухвалює рішення), як записують доступ (Access & Roles), із чого складається SDK (Як побудовано SDK) і як його виконують (Потоки, час життя і тести).
- Далі — модулі. Усі сторінки модулів мають однакову структуру, тож кожну наступну читати легше. На дві з них спирається все інше: Entity і Data & Subscriptions.
Що читати залежно від ролі
Хоч би яка у вас роль, почніть зі сторінки Хто ухвалює рішення: один SDK, у якому кожен актор має свої видані права (grant), — спільна основа для всіх. А Основні поняття тримайте відкритими поруч.
| Хто ви | Що читати, по черзі |
|---|---|
| Розробник клієнта гри (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 |
| Розробник виділеного сервера на Unreal | Rooms (розділ «Хостинг кімнати») → Bots → Locomotion · об’єкти світу → Map → Що лишається після втрати хоста |