Esta página aún no está traducida: se muestra la versión en inglés.
Primeros pasos
Una arena jugable (mapa, tanques, disparos y botín), declarada de principio a fin. Nada de lo que sigue es un bucle de juego: la simulación se ejecuta dentro de la plataforma.
Antes de empezar
Necesitas un proyecto con un entorno (environment) dev (se crea en el plano de operador, que gestiona ese ciclo de vida), la CLI playserv con la sesión iniciada en ese proyecto y el paquete del SDK para tu binding. En tu juego no se instala nada más.
Cada llamada que haces tú aparece en los ejemplos de abajo; entre una y otra, la plataforma actúa según lo que dice cada declaración. La habilidad, la estadística y la tabla de botín que se usan abajo son presets de entidad: declaraciones sobre entidades, no módulos independientes.
Declara el mundo
Una entidad es tu esquema más sus aspectos en vivo. Cada comportamiento es un atributo, junto al campo (field) que describe:
Tank: tres políticas de sincronización y tres aspectos de juego, una línea para cada uno[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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
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;
}Modificar un campo [Sync] ya es la operación de red. No hay que armar ningún snapshot ni hacer ninguna llamada de envío.
Ninguno de esos tipos lo defines tú, y cada uno pertenece a una página:
| En el bloque | De dónde viene |
|---|---|
Vector3, Stat | el paquete base de tu binding |
Body y las formas de cuerpo | Collision |
Motion y los cinco modelos de movimiento | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | los presets de entidad que los usan |
EntryRequest, Verdict, StatEvent | argumentos de los hooks, que te pasa el módulo donde conectas el hook |
Seat | Matchmaking |
Scope y los alcances de sincronización | Visibility |
Tick y las frecuencias de tick | Rooms |
Los enums son cerrados. Una regla que ningún miembro cubre se escribe como predicado, no como un miembro nuevo: con [Aspect("loadout", Visible = "owner == caller.player")] se expresa la visibilidad por campo cuando Scope.Owner no es exactamente la regla que querías (Data & Subscriptions).
Declara la sala
Una plantilla de sala (room template) define qué es una sesión y nombra las declaraciones en las que se apoya. No hay ninguna clase de sala (room) que heredar ni un método de tick que completar, porque el funcionamiento interno de la sala es cosa de la plataforma:
battle y las tres declaraciones que nombra: una arena, una tabla de botín y un 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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
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;
}Los ids entre comillas son claves de contenido, no texto libre:
| Clave | Qué nombra |
|---|---|
rock | un objeto del conjunto de obstáculos del mapa (Map) |
ammo.shell, railgun | artículos del catálogo (Catalog & Commerce); gracias a eso, el disparo descuenta munición y lo que recoges termina en el inventario |
battle, arena, crate-loot, shell | las claves que registran estas cuatro declaraciones |
playserv push rechaza una declaración cuya clave no existe en el entorno de destino, así que una clave mal escrita falla al desplegar y no la primera vez que alguien usa la habilidad. Esté donde esté la plantilla, puedes reajustarla sin volver a desplegar el build del motor: el modelo subido es lo que live-ops edita en el panel.
Escribe tus reglas como hooks
Los hooks son funciones en la nube que la plataforma llama en pasos con nombre. Reciben y devuelven tipos concretos: la firma no lleva un objeto de contexto ni un logger.
[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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unreal se suscribe a los eventos que produce. Están previstas funciones escritas desde el motor: grafos de Blueprint y C++ (traducido a un formato de script que la plataforma valida). Ambos se analizan y se suben como cualquier declaración, y la ejecución sigue en la plataforma.
Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unity se suscribe a los eventos que produce.
Un filtro Before puede rechazar el paso; un observador After se ejecuta cuando el paso ya se confirmó y no puede rechazarlo. Por eso, si el paquete inicial no llega, se pierden 20 proyectiles, no el inicio de sesión. El resto está en Extensibility.
Qué hay detrás de cada línea de ese bloque:
| La línea | Qué la ejecuta en realidad |
|---|---|
se dispara Stats.Depleted | la estadística (stat) llega a su mínimo: 0 para Hp, porque la declaración solo fija Max |
| la transición a la muerte | AtMin = "death" en el paso 1; el hook agrega la consecuencia, no la transición |
| el daño | [Effect(Damage = 35)] en el proyectil, que la plataforma aplica cuando impacta |
RollAt | se genera sobre la declaración [DropTable]; por eso la clase es partial y por eso en la pestaña de Go aparece drops.RollAtCrateLoot |
| la recogida | al pasar por encima del botín, los artículos se transfieren de forma atómica al inventario del jugador; esa transferencia es el evento changed que dibuja el HUD |
Tipos generados para el motor
playserv schema codegen # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/GeneratedEjecútalo (o deja que lo ejecute la CI) cada vez que subas el esquema. Los tipos se regeneran y nunca se editan a mano: el Tank generado es el Tank subido. playserv push lee un proyecto de motor igual que un proyecto de servidor: los especificadores de UHT y los atributos de C# ya son la declaración, así que para exportar basta con apuntar la CLI al proyecto de UE o de Unity. En qué hilo llega un callback y cuándo termina una suscripción lo fija el modelo de runtime: Hilos, ciclo de vida y pruebas.
Conecta un cliente
La API del cliente es simétrica: los módulos son los mismos, y lo que un build puede llamar lo decide la clave con la que se ejecuta. Un build del motor lleva una clave de jugador (player key): aquí es projectKey, la credencial de un proyecto y un entorno, que se emite en el panel y viaja dentro del build. No nombra ningún rol: los roles se resuelven en el servidor en cada solicitud, y el jugador al que se aplican llega con SignIn. Aquí los bindings del motor son los protagonistas; los bindings de servidor usan la misma interfaz en modo headless (el cerebro de un bot, una prueba de carga, una herramienta de operaciones):
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)Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
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);Cuatro llamadas, y cada una responde algo distinto:
| Llamada | Con qué responde |
|---|---|
Find | un ticket colocado y un lugar (seat) reservado en una sala. La reserva dura el plazo que declara la plantilla; si vence, pierdes el lugar, no el derecho a jugar |
Join | se resuelve cuando llega el estado actual de la sala. El Tank que la plantilla genera para cada miembro que entra forma parte de ese estado, así que My<Tank>() responde en la línea siguiente y todo lo que viene después de Join es tráfico en vivo |
Cast | disparar es el verbo de la habilidad (ability), no una segunda interfaz: una declaración [Projectile] es una habilidad con balística encima, así que Cast revisa el tiempo de recarga, la munición y el objetivo igual que con un dash o una curación (Entity Presets) |
Abilities | se genera: la generación de código reúne las habilidades y los proyectiles declarados en un solo tipo por binding |
Los rechazos llegan como el Problem tipado de la plataforma: un código y un motivo legible para una persona. C#, TypeScript, Python y Unreal lo lanzan como excepción; Go lo devuelve como valor de error, y por eso en esa pestaña se revisa cada llamada. Un servidor dedicado de Unreal o un cliente maestro ejecutan el mismo binario con una clave de host (host key), y sus roles incluyen las filas mc: en Rooms, Alojar una sala recorre esa interfaz de punta a punta, y en Access & Roles se declaran las claves y los roles que hay detrás.
Sube y juega
playserv push # schema + declarations + hooks, one deploy
playserv open battle # a dev-env room, live in the panelplayserv push escanea el proyecto donde se ejecuta (hooks con atributos y declaraciones) y despliega en el entorno que elijas (--env dev por defecto). playserv schema push por sí solo sube únicamente el modelo, y playserv schema diff muestra la referencia contra la que se compara un push (Esquema como código).
Un push se aplica completo o no se aplica, y se rechaza en lugar de fusionarse si el esquema desplegado cambió desde tu diff. Pasar un modelo de dev a prod es una acción del plano de operador (operator plane), no una llamada del SDK (el plano de operador).
Los cambios incompatibles se convierten en migraciones
Un cambio que rompería los datos existentes no viaja con el push. Se convierte en una migración que primero lees y después ejecutas o cancelas (Esquema como código).
playserv open battle crea una sala a partir de la plantilla battle subida y la abre en el panel, donde puedes inspeccionar el estado de la sala y sus miembros mientras juegas en ella. El panel ya muestra la plantilla, el mapa, la tabla de botín (drop table) y los hooks: el mismo modelo que escribiste en código, que también puedes editar ahí.
Los números que no elegiste
Capacity = 8, Hz30, Hz = 10 y Cooldown = 1.5f son los ajustes de este juego, no techos. Los límites de la plataforma están por encima, y para cada uno está definido lo que ve quien llama cuando lo alcanza:
| En el límite | Qué recibe quien llama |
|---|---|
| entrar a una sala llena o cerrada | conflict: vale la pena reintentar cuando se libere un lugar |
| crear salas por encima del límite por proyecto o por actor | un rechazo; no se elimina nada de lo que ya estaba creado |
| crear salas o iniciar sesión demasiado rápido | un rechazo por límite de frecuencia que indica cuánto esperar |
| un evento con datos por encima del máximo de la sala | un rechazo antes del envío; los datos nunca se truncan |
| una lectura que supera el máximo de filas de un rol | tantas filas como permite el máximo, más una marca que indica que se recortó |
Los números en sí dependen del entorno y llegarán con los límites de la plataforma; el comportamiento en el límite no espera a que lleguen (Rooms, Access & Roles, Auth & Players).
Siguientes pasos
- Ejemplos, la sección que viene justo después de esta: una tabla de clasificación en Tanks, botiquines en Tanks o la receta del torneo diario para el bucle meta. Cada uno construye una funcionalidad real, y cada paso enlaza a la página del módulo responsable de lo que acabas de usar.
- Cómo funciona el SDK, cuando su forma empiece a importarte más que la próxima funcionalidad: Conceptos básicos es el diccionario, y cuatro artículos responden quién llama (Quién toma las decisiones), cómo se escriben los permisos otorgados (grant) (Access & Roles), de qué está hecho el SDK (Cómo está construido el SDK) y cómo se ejecuta (Hilos, ciclo de vida y pruebas).
- Después, los módulos. Todas las páginas de módulo siguen la misma estructura, así que cada una se lee más rápido que la anterior. Entity y Data & Subscriptions son las dos en las que se apoya todo lo demás.
Rutas de lectura por rol
Sea cual sea tu rol, lee primero Quién toma las decisiones, porque un solo SDK con permisos otorgados para cada actor es la base común, y ten Conceptos básicos abierto al lado.
| Si eres | Qué leer, en orden |
|---|---|
| Desarrollador de cliente (Unity · cliente de Unreal · TS) | Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, y luego según la funcionalidad: Inventory · Leaderboards · Messaging · Profile |
| Desarrollador de servidor (C# · TS · Python · Go) | Esquema como código → los bloques de construcción → Entity → Extensibility → Access & Roles, y luego los módulos cuyas declaraciones te tocan: Rooms · Matchmaking · Leaderboards · Catalog & Commerce |
| Desarrollador de servidor dedicado en Unreal | Rooms (Alojar una sala) → Bots → Locomotion · objetos del mundo → Map → Qué sobrevive a la pérdida de un host |