Cette page n’est pas encore traduite : la version anglaise est affichée.
Premiers pas
Une arène jouable (carte, chars, tirs, butin), déclarée de bout en bout. Rien de ce qui suit n’est une boucle de jeu : la simulation tourne dans la plateforme.
Avant de commencer
Il vous faut un projet doté d’un environnement dev (créé sur le plan opérateur, qui gère ce cycle de vie), la CLI playserv connectée à ce projet et le package SDK de votre binding. Rien d’autre ne s’installe dans votre jeu.
Chaque appel que vous faites figure dans les exemples ci-dessous ; entre deux appels, c’est la plateforme qui agit selon ce que dit une déclaration. Trois éléments utilisés plus bas sont des presets d’entité, c’est-à-dire des déclarations posées sur des entités et non des modules à part entière : la capacité (ability), la statistique et la table de butin (drop table).
Déclarer le monde
Une entité, c’est votre schéma plus ses aspects dynamiques. Un attribut par comportement, placé à côté du champ (field) qu’il décrit :
Tank : trois politiques de synchronisation et trois aspects de gameplay, une ligne chacun[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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
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;
}Modifier un champ [Sync], c’est déjà l’opération réseau. Pas de snapshot à assembler, pas d’appel d’envoi à écrire.
Aucun de ces types n’est à définir de votre côté, et chacun relève d’une seule page :
| Dans le bloc | Vient de |
|---|---|
Vector3, Stat | le package de base de votre binding |
Body et les formes de corps | Collision |
Motion et les cinq modèles de mouvement | Locomotion |
ObstacleSet, Drop, Flight, Ammo, Effect | les presets d’entité qui les utilisent |
EntryRequest, Verdict, StatEvent | les données reçues par les hooks, fournies par le module auquel le hook se rattache |
Seat | Matchmaking |
Scope et les portées de synchronisation | Visibility |
Tick et les fréquences de tick | Rooms |
Les énumérations sont fermées. Une règle qu’aucun membre ne couvre s’écrit comme un prédicat, pas comme un nouveau membre : [Aspect("loadout", Visible = "owner == caller.player")] exprime une visibilité par champ quand Scope.Owner ne correspond pas tout à fait à la règle voulue (Data & Subscriptions).
Déclarer la salle
Un modèle de salle (room template) décrit ce qu’est une session et nomme les déclarations sur lesquelles il s’appuie. Pas de classe de salle dont hériter, pas de méthode de tick à remplir, car le fonctionnement interne d’une salle appartient à la plateforme :
battle et les trois déclarations qu’il nomme : une arène, une table de butin, une arme[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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
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;
}Les identifiants entre guillemets sont des clés de contenu, pas du texte libre :
| Clé | Ce qu’elle désigne |
|---|---|
rock | un élément de décor du jeu d’obstacles de la carte (Map) |
ammo.shell, railgun | des objets du catalogue (Catalog & Commerce) — c’est aussi grâce à eux que le tir débite les munitions et que le butin ramassé atterrit dans l’inventaire |
battle, arena, crate-loot, shell | les clés qu’enregistrent ces quatre déclarations |
playserv push refuse une déclaration dont la clé n’existe pas dans l’environnement ciblé : une clé mal saisie échoue donc au déploiement, et non au premier lancement de la capacité. Où que vive le modèle, il reste réglable sans redéployer le build du jeu : c’est le modèle publié que les live-ops modifient dans le panneau d’administration.
Écrire vos règles sous forme de hooks
Les hooks sont des fonctions cloud que la plateforme appelle à des étapes nommées. Entrée typée, sortie typée : ni objet de contexte fourre-tout ni logger dans la signature.
[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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python — Unreal s’abonne aux événements qui en résultent. Des fonctions écrites côté moteur sont prévues : des graphes Blueprint, et du C++ (traduit vers une cible de script validée par la plateforme). Les deux seront analysés et publiés comme n’importe quelle déclaration ; l’exécution reste sur la plateforme.
Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python — Unity s’abonne aux événements qui en résultent.
Un filtre Before peut refuser l’étape ; un observateur After s’exécute une fois l’étape validée et ne le peut plus. Un kit de départ qui n’arrive pas coûte donc 20 obus, pas la connexion. La suite se trouve dans Extensibility.
Ce qui exécute réellement chaque ligne de ce bloc :
| La ligne | Ce qui l’exécute réellement |
|---|---|
Stats.Depleted se déclenche | la statistique atteint son plancher — 0 pour Hp, puisque la déclaration ne fixe que Max |
| la transition vers la mort | AtMin = "death" à l’étape 1 ; le hook ajoute la conséquence, pas la transition |
| les dégâts | [Effect(Damage = 35)] sur l’obus, que la plateforme applique à l’impact |
RollAt | généré sur la déclaration [DropTable] — d’où le partial, et d’où le drops.RollAtCrateLoot de l’onglet Go |
| le ramassage | rouler sur le butin transfère les objets dans l’inventaire du joueur, de façon atomique ; ce transfert est l’événement changed qu’affiche le HUD |
Types générés pour le moteur
playserv schema codegen # Unreal C++ → Plugins/PlayServ/Generated · Unity C# → Packages/com.playserv.sdk/GeneratedLancez cette commande (ou laissez la CI le faire) après chaque publication du schéma. Les types sont régénérés, jamais modifiés à la main, et le Tank généré est exactement le Tank publié. playserv push lit un projet moteur exactement comme un projet serveur : les spécificateurs UHT et les attributs C# sont la déclaration, si bien que pour exporter, il suffit de pointer la CLI sur le projet UE ou Unity. Le thread sur lequel arrive un callback et le moment où prend fin un abonnement (subscription) sont fixés par le modèle d’exécution — Threads, cycle de vie et tests.
Connecter un client
L’API client est symétrique : ce sont les mêmes modules, et c’est la clé sous laquelle tourne un build qui décide de ce qu’il peut appeler. Un build moteur embarque une clé joueur (player key) — ici projectKey, l’identifiant d’un projet et d’un environnement, émis dans le panneau et livré dans le build. Elle ne nomme aucun rôle : les rôles se résolvent côté serveur à chaque requête, et le joueur qui les porte arrive avec SignIn. Les bindings moteur sont ici au premier plan ; les bindings serveur pilotent la même interface sans interface graphique (un cerveau de bot, un test de charge, un outil d’exploitation) :
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)La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.
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);Quatre appels, et chacun répond à une question différente :
| Appel | Ce qu’il renvoie |
|---|---|
Find | un ticket placé et une place (seat) réservée dans une salle. La réservation tient pendant la durée que déclare le modèle ; la laisser expirer coûte la place, pas le droit de jouer |
Join | se résout une fois arrivé l’état courant de la salle. Le Tank que le modèle fait apparaître pour chaque membre entrant fait partie de cet état : My<Tank>() répond donc dès la ligne suivante, et tout ce qui suit Join est du trafic en direct |
Cast | tirer passe par le verbe des capacités, pas par une seconde interface : une déclaration [Projectile] est une capacité avec de la balistique en plus, donc Cast vérifie le temps de recharge, les munitions et la cible exactement comme pour un dash ou un soin (Entity Presets) |
Abilities | généré — la génération de code rassemble les capacités et les projectiles déclarés en un seul type par binding |
Les refus arrivent sous la forme du Problem typé de la plateforme : un code et un motif lisible par un humain. C#, TypeScript, Python et Unreal le lèvent comme exception ; Go le renvoie comme valeur d’erreur, d’où la vérification de chaque appel dans cet onglet. Un serveur dédié Unreal ou un client maître (master-client) exécute le même binaire. Seule la clé change : c’est une clé hôte (host key), et ses rôles portent les lignes mc. Dans Rooms, Héberger une salle décrit cette interface de bout en bout, et Access & Roles montre où se déclarent les clés et les rôles qui la sous-tendent.
Publier et jouer
playserv push # schema + declarations + hooks, one deploy
playserv open battle # a dev-env room, live in the panelplayserv push analyse le projet dans lequel il tourne — hooks à attributs et déclarations — et déploie dans l’environnement ciblé (--env dev par défaut). playserv schema push seul ne publie que le modèle, et playserv schema diff montre ce à quoi une publication sera comparée (Le schéma dans le code).
Une publication s’applique en entier ou pas du tout, et elle est refusée, pas fusionnée, si le schéma déployé a changé depuis votre diff. Faire passer un modèle de dev à prod relève du plan opérateur (operator plane), pas d’un appel SDK (le plan opérateur).
Les changements cassants deviennent des migrations
Un changement qui casserait les données existantes ne part pas avec la publication. Il devient une migration que vous relisez d’abord, puis que vous exécutez ou annulez (Le schéma dans le code).
playserv open battle crée une salle à partir du modèle battle publié et l’ouvre dans le panneau, où vous pouvez inspecter son état et ses membres pendant que vous y jouez. Le panneau affiche désormais le modèle de salle, la carte, la table de butin et les hooks : ce que vous avez écrit dans le code, modifiable là aussi.
Les chiffres que vous n’avez pas choisis
Capacity = 8, Hz30, Hz = 10 et Cooldown = 1.5f sont les réglages de ce jeu, pas des plafonds. Les limites propres à la plateforme se situent au-dessus, et chacune est déclarée avec ce que l’appelant observe lorsqu’il l’atteint :
| À la limite | Ce que reçoit l’appelant |
|---|---|
| rejoindre une salle pleine ou fermée | conflict — à retenter quand une place se libère |
| créer des salles au-delà de la limite par projet ou par acteur | un refus ; rien de ce qui existe déjà n’est supprimé |
| créer des salles ou se connecter trop vite | un refus pour dépassement de débit, qui indique le délai à attendre |
| un événement dont les données dépassent le plafond de la salle | un refus avant l’envoi ; rien n’est jamais tronqué |
| une lecture au-delà du plafond de lignes d’un rôle | autant de lignes que le plafond en autorise, plus un marqueur signalant la coupe |
Les valeurs elles-mêmes dépendent de l’environnement et arriveront avec les limites de la plateforme ; le comportement à la limite, lui, n’attend pas (Rooms, Access & Roles, Auth & Players).
Pour aller plus loin
- Exemples, la section qui suit immédiatement : un classement dans Tanks, des trousses de soin dans Tanks ou la recette du tournoi quotidien pour la boucle méta. Chaque fois une vraie fonctionnalité, et chaque étape renvoie à la page du module dont relève ce que vous venez d’utiliser.
- Fonctionnement du SDK, quand sa forme commence à compter davantage que la prochaine fonctionnalité : Concepts clés sert de dictionnaire, et quatre articles répondent à qui appelle (Qui prend les décisions), comment s’écrivent les droits accordés (Access & Roles), de quoi est fait le SDK (Comment le SDK est construit) et comment il s’exécute (Threads, cycle de vie et tests).
- Ensuite, les modules. Toutes les pages de module suivent la même structure : chacune se lit donc plus vite que la précédente. Entity et Data & Subscriptions sont les deux sur lesquelles tout le reste s’appuie.
Parcours de lecture par rôle
Quel que soit votre rôle, commencez par Qui prend les décisions : un seul SDK, avec des droits accordés (grant) propres à chaque acteur, c’est le prérequis commun. Gardez Concepts clés ouvert à côté.
| Vous êtes | À lire, dans l’ordre |
|---|---|
| Développeur client (Unity · client Unreal · TS) | Auth & Players → Matchmaking → Rooms → Entity → Data & Subscriptions, puis selon la fonctionnalité : Inventory · Leaderboards · Messaging · Profile |
| Développeur serveur (C# · TS · Python · Go) | Le schéma dans le code → les briques de base → Entity → Extensibility → Access & Roles, puis les modules dont vous possédez les déclarations : Rooms · Matchmaking · Leaderboards · Catalog & Commerce |
| Développeur de serveur dédié Unreal | Rooms (Héberger une salle) → Bots → Locomotion · objets du monde → Map → Ce qui survit à la perte d’un hôte |