Aller au contenu principal
Le jeu en direct

Cette page n’est pas encore traduite : la version anglaise est affichée.

Rooms

Une salle est une session de jeu, et peu importe à la plateforme ce qui l’héberge. Une seule abstraction couvre un serveur dédié par match, une grande carte partagée découpée en couches logiques, une salle hébergée par un client maître et un mini-jeu hébergé par le backend. Le fonctionnement interne d’une salle est notre affaire ; vous la pilotez de l’extérieur.

Quand l’utiliser

  • Votre jeu a des sessions (matchs, lobbies, donjons, courses), et quelque chose doit prendre en charge leur cycle de vie, l’appartenance (membership) et les reconnexions.
  • Vous hébergez sur des serveurs dédiés, sur le client maître d’un joueur ou sur le backend lui-même, et il faut y acheminer les joueurs.
  • Une seule carte (map) partagée doit faire tourner de nombreuses sessions logiques : ce sont des couches, délimitées par Visibility.
  • Des joueurs arrivent en cours de session et doivent voir l’état actuel : l’état de la salle (room) à leur arrivée, puis le trafic en direct.
  • Une déconnexion ne doit pas faire perdre sa place (seat) : pendant le délai de grâce du modèle (45 s dans battle), le joueur retrouve la même appartenance.
  • Inutile quand une fonctionnalité se résume à des requêtes et des réponses sur des enregistrements : Data & Subscriptions suffit.

Qui fait quoi

ActeurCe qu’il fait sur cette page
room-ownerenregistre des salles depuis n’importe quel point du processus ; sur une instance de salle, dispose de l’interface d’administration propre à cette instance : modifie la configuration en direct, expulse, verrouille, diffuse, détruit la salle
entry-validatoraccepte ou refuse les demandes d’entrée, avec un code et un motif
room-visitorparcourt les salles, en rejoint une en fournissant des données, se reconnecte pendant le délai de grâce (grace window), repart
spectatorrejoint la salle sans prendre part à la partie ; reçoit les diffusions et le trafic en direct
match-organizerréserve des places qui comptent dans la capacité ; une réservation expire au terme fixé par le modèle de salle (room template), soit 90 s dans battle

En bref

Le modèle battle : capacité, tick, type d’hôte, une carte nommée et les deux délais qui protègent une place
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}
@RoomTemplate('battle')
export class Battle {
  static capacity = 8;
  static tick = Tick.hz30;
  static host = Host.backend;                // or Host.dedicatedServer, Host.masterClient
  static map = Maps.named('arena-caves-v3');
  static grace = seconds(45);                // a dropped member keeps the seat this long
  static reserve = seconds(90);              // a reserved seat is held this long
}
@room_template("battle")
class Battle:
    capacity = 8
    tick = Tick.HZ30
    host = Host.BACKEND                      # or Host.DEDICATED_SERVER, Host.MASTER_CLIENT
    map = maps.named("arena-caves-v3")
    grace = seconds(45)                      # a dropped member keeps the seat this long
    reserve = seconds(90)                    # a reserved seat is held this long
Bientôt — Go

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", Capacity = 8, Tick = 30,
                         Host = "Backend",                  // or "DedicatedServer", "MasterClient"
                         Map = "arena-caves-v3",
                         Grace = "45s",                     // a dropped member keeps the seat
                         Reserve = "90s"))                  // a reserved seat is held
struct FBattle { GENERATED_BODY() };
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — la même classe de modèle compile dans Unity, sur la base 2021.3 — aucune syntaxe C# 12 ici
[RoomTemplate("battle")]
public static class Battle
{
    public static int Capacity = 8;
    public static Tick Tick = Tick.Hz30;
    public static Host Host = Host.Backend;          // or DedicatedServer, MasterClient
    public static MapRef Map = Maps.Named("arena-caves-v3");
    public static Duration Grace = 45.Seconds();     // a dropped member keeps the seat this long
    public static Duration Reserve = 90.Seconds();   // a reserved seat is held this long
}

Où qu’il ait été écrit, le modèle publié est versionné et modifiable dans le panneau : l’équipe live-ops réajuste un type de salle sans redéployer le moteur. Héberger les salles qui en sont issues passe par la même interface, atteinte avec un autre rôle, et un serveur dédié Unreal comme un client maître en disposent entièrement : enregistrer, héberger plusieurs salles par processus, modifier la configuration en direct, expulser, publier, détruire.

Le hook entry-validator : les joueurs bannis sont refusés à l’entrée, avec un code et un motif
[Before(Rooms.Entry, room: "battle")]                 // the entry-validator interface
public static Verdict ValidateEntry(EntryRequest entry) =>
    entry.Player.IsBanned
        ? Entry.Reject(Problem.Banned, "banned from this project")
        : Entry.Accept();
// the entry-validator interface
export const validateEntry = before(Rooms.entry, { room: 'battle' },
  (entry: EntryRequest) =>
    entry.player.isBanned
      ? Entry.reject(Problem.banned, 'banned from this project')
      : Entry.accept());
@before(rooms.entry, room="battle")                   # the entry-validator interface
def validate_entry(entry: EntryRequest) -> Verdict:
    if entry.player.is_banned:
        return entry.reject(Problem.BANNED, "banned from this project")
    return entry.accept()
Bientôt — Go

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.

S’exécute hors du moteur

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 qu’il produit. Des fonctions écrites dans le moteur sont prévues : graphes Blueprint et C++ (traduit vers une cible de script validée par la plateforme). Les unes comme les autres sont analysées et publiées comme toute déclaration ; l’exécution reste sur la plateforme.

S’exécute hors du moteur

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 qu’il produit.

Côté client :

Filtrer les salles CTF, en rejoindre une avec son équipement, réagir aux arrivées
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));
const rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity");
const room = await playserv.rooms.join(rooms[0], { with: { loadout: 'scout' } });
room.onMemberJoined((m) => hud.add(m));
rooms = await playserv.rooms.browse("mode == 'ctf' && players < capacity")
room = await playserv.rooms.join(rooms[0], with_data={"loadout": "scout"})
room.on_member_joined(lambda m: hud.add(m))
Bientôt — Go

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.

Client->Rooms->Of<FBattle>()->Select()
    .Where(PSFields::Room::Mode == TEXT("ctf"))
    .Then(TPSOnResult<TPSPage<FPSRoomInfo>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSRoomInfo>>& Found)
    {
        if (!Found.HasValue()) { return; }
        // join the first match; the join data rides along
        Client->Rooms->Join(Found.Value().Rows[0], FPSJoinData{{ TEXT("loadout"), TEXT("scout") }},
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                TPSSubscription Roster = JoinResult.Value()->Subscribe->Presence(
                    [this](const FPSPresence& Presence) { Hud->Add(Presence); });
            }));
    }));
// 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 — fonctionne tel quel dans Unity avec les types générés
var rooms = await playserv.Rooms.Browse("mode == 'ctf' && players < capacity");
var room  = await playserv.Rooms.Join(rooms.First(), with: new { loadout = "scout" });
room.OnMemberJoined(m => Hud.Add(m));

Le modèle

Une salle est un groupe doté de règles, et ce n’est pas une entité. L’appartenance vient des groupes ; l’état système de la salle relève de la plateforme, tandis que l’état du jeu vit dans des entités rattachées à la salle. Et aucun code du studio ne s’exécute dans une salle, quel que soit le mode d’autorité.

Ce que déclare un type de salle.

DéclareCe que c’est
capacitéen places, la place étant l’unité de capacité distincte de l’appartenance : on peut la réserver avant l’arrivée et la conserver pendant une inactivité. Une réservation est limitée dans le temps, avec une échéance déclarée au-delà de laquelle la place est libérée si personne n’est arrivé
visibilitélistée · accessible par nom ou par code · cachée
mode de créationl’un des trois, et le mode à la première arrivée est tenu de déclarer un hook d’initialisation
deux délais indépendantsle délai d’inactivité, qui fixe combien de temps un participant peut rester silencieux, et le TTL d’une salle vide, qui fixe combien de temps survit une salle où il n’y a personne
fenêtre de reconnexiontant qu’elle court (rejoin window), un retour restitue la même appartenance et la même place, au lieu de créer un nouveau participant
mode d'autoritésimulation de la plateforme ou autorité externe, et il n’y a pas de valeur par défaut
confiance dans un résultat rapportépour une autorité externe : l’accepter · le vérifier par un hook · ne pas l’accepter. Là non plus, pas de valeur par défaut
comportement à la perte de l'hôteattendre la fin du délai de grâce · fermer la salle · admettre un remplaçant
instance de carte et strates du mondefacultatif : l’instance qu’elle occupe et les strates qu’elle y utilise
entités rattachées à la salleles entités du studio qui ont la portée de la salle, exprimées par un prédicat et non par un nouveau mécanisme

Les deux machines à états.

PourÉtats
une sallecreated → open → closed → torn down, où torn down est terminal et où closed signifie « plus de nouvelles arrivées », et non « disparue »
une appartenanceactive ⇄ inactive → departed, departed étant terminal pour cette appartenance

Ce qui vaut pour toute salle.

ToujoursCe que c’est
perdre la connexion et partirsont deux événements différents, et l’issue de la fenêtre est observable : « revenu » et « la fenêtre a expiré » se distinguent, si bien qu’un client n’a jamais à deviner ce qui s’est passé
un spectateurn’est pas un participant au rabais : il est présent, n’occupe aucune place et ne fait pas partie de la composition (roster) qu’on désigne comme « les joueurs »
pas de rôles dans la sallele propriétaire de la salle (room owner) est un acteur qui détient un droit (Access & Roles), pas un rang dans la liste des membres
la présencea un historique, la composition non : on conserve qui est arrivé, a décroché, est revenu et est parti ; les changements de composition ne forment pas un second historique
l'interfaceest celle d’une salle précise : vous vous adressez à cette salle, pas seulement à son type
trois axes, pas deuxl’API qui couvre toutes les salles (parcourir, enregistrer, lister) ; l’API propre à une salle, qu’appelle n’importe quel membre (rejoindre, quitter) ; et une interface d’administration par instance — expulser, verrouiller, modifier la configuration, fermer, détruire cette salle —, réservée à qui détient le rôle d’administrateur ou d’hôte de cette instance précise, et non aux membres
one room type — one set of declarationsexternal authorityour simulationdedicated server · host keymaster client · room-ownerbackend · backend-service a match on a fleeta player's machinea mini-game
Qui exécute le tick : un seul type de salle, ses hôtes regroupés par mode d’autorité

Les deux modes d’autorité.

ModeQui exécute le tick
simulation de la plateformenotre implémentation de la salle et ses modules
autorité externeun processus qui exécute notre SDK et siège dans la salle avec un rôle faisant autorité : le serveur de jeu du studio, ou le client d’un joueur, en tant que client maître

Ce que le mode décide, et ce qu’il ne décide pas.

Ce que c’est
la frontièrepasse par le rôle, et non par le propriétaire du processus. Un serveur dédié est le même client, sans le rendu ; ce qui le distingue de la machine d’un joueur, c’est la confiance, pas la construction. C’est aussi pourquoi le pair-à-pair n’a pas besoin d’un troisième mode : c’est une salle en mode autorité externe dont l’autorité est un client hôte
ce qui ne change pasles règles d’entrée, la présence, la reconnexion et chaque déclaration, dans les trois cas. Seuls diffèrent le processus qui détient l’autorité et la part d’autorité qui lui est accordée
la salle ne déménage pasdans aucun des deux modes le code du studio ne s’exécute dans une salle, et l’état de session comme l’état persistant restent chez nous. Un client maître est un membre doté d’un rôle faisant autorité : le tick y est calculé, mais la salle n’y vit pas

Héberger une salle.

Enregistrer des salles à partir du modèle publié : une clé d’idempotence pour chacune, plusieurs par processus
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
const room   = await playserv.rooms.register('battle', { key: 'caves-eu-1' });
const second = await playserv.rooms.register('battle', { key: 'caves-eu-2' }); // several per process

room.onMemberJoined((m) => seat(m));
await room.setConfig((c) => c.set('mapRotation', 'night'));

await room.dispose();
room   = await playserv.rooms.register("battle", key="caves-eu-1")
second = await playserv.rooms.register("battle", key="caves-eu-2")   # several per process

room.on_member_joined(lambda m: seat(m))
await room.set_config(lambda c: c.set("mapRotation", "night"))

await room.dispose()
Bientôt — Go

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.

Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-1")),
    TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnRoomUp(Result.Value());
    }));
Client->Rooms->Of<FBattle>()->Create(FPSIdempotencyKey(TEXT("caves-eu-2")), OnSecondRoom);

// in OnRoomUp(FPSRoom* Room):
TPSSubscription Roster = Room->Subscribe->Presence([this](const FPSPresence& Presence) { Seat(Presence); });
Room->Config->Modify({ .MapRotation = TEXT("night") });

Room->Delete();   // demolish — the declared end of the room's existence
// 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 — en tant que build client maître — un client qui enregistre la salle dispose au runtime de la même interface d’hôte
var room   = await playserv.Rooms.Register("battle", key: "caves-eu-1");
var second = await playserv.Rooms.Register("battle", key: "caves-eu-2"); // several per process

room.OnMemberJoined(m => Seat(m));
await room.SetConfig(c => c.Set("mapRotation", "night"));                // live config, no restart

await room.Dispose();
ToujoursCe que c’est
le handleest le même objet que dans l’onglet client : pas d’amorçage propre à l’hôte, pas de handle réservé au serveur. Il répond à ces appels parce que le rôle de l’acteur les inclut au runtime — un serveur dédié ou un client maître qui tourne sous une clé hôte (Access & Roles)
l'enregistrementprend une clé d’idempotence, faute de quoi un timeout serait irrattrapable : répétez l’appel avec la même clé et vous récupérez la même salle, et non une seconde dont personne ne connaît l’adresse

Entrée, présence et reconnexion.

seat reserved by Matchmakingseat freed, with an eventyour entry hookrefused — a code and a reasonactive member — room state, then live trafficinactive — the seat is helddeparted — a leave, with an event 90 s, no joinjoin with datarejectedacceptedconnection lostback in 45 s45 s passed
La vie d’une place : réservée, contrôlée à l’entrée, conservée pendant une déconnexion (90 s et 45 s sont les valeurs du modèle battle)
Ce que c’est
l'entréeest une demande validée : celui qui arrive fournit des données, et le hook d’entrée accepte ou refuse avec un code et un motif. C’est à partir de ces données que le membre s’initialise — dans battle, le Tank du membre apparaît avec l’équipement apporté à l’arrivée
places et réservationsle module Matchmaking réserve une place pour la durée fixée par le modèle (90 secondes dans battle), puis le client rejoint la salle directement. Les réservations comptent dans la capacité, et leur expiration libère la place avec un événement, pas en silence
une déconnexion n'est pas un départun membre déconnecté garde sa place pendant le délai de grâce (45 s dans battle) et retrouve la même appartenance en se reconnectant ; à l’expiration, la déconnexion devient un départ, et l’événement indique de quel cas il s’agit. Revenir une seconde trop tard, c’est apprendre que la salle existe toujours mais plus l’appartenance — une autre réponse que « cette salle n’existe pas »
une arrivée tardive reçoit l'état, pas un journalcelui qui arrive reçoit l’état actuel de la salle, puis le trafic en direct

Les événements ne sont pas rejoués après une reconnexion

Les événements envoyés pendant l’absence d’un joueur ne sont pas rejoués, ni pour celui qui arrive en retard ni pour un membre qui revient. Tout ce qui doit survivre à cette coupure doit être de l’état : la mine posée par un joueur est une entité rattachée à la salle, pas un message MinePlaced que quelqu’un doit attraper au vol.

Erreurs

  • Une salle qui n’existe pas ou qu’un prédicat masque, ainsi qu’une salle démantelée, répondent not found : un refus ne révèle jamais une salle que vous n’avez pas le droit de voir.
  • La capacité épuisée est un conflit, et les places réservées comptent comme occupées ; réessayez dès qu’une place se libère. Une salle fermée aux arrivées est aussi un conflit, à réessayer si elle rouvre.
  • Une arrivée refusée par une règle est un conflit ; refusée par un hook, elle porte le code et le motif propres au hook, si bien qu'« une règle du jeu a dit non » ne ressemble jamais à une panne de transport.
  • Une fenêtre de reconnexion expirée est un conflit : rejoignez la salle comme nouveau participant, avec une nouvelle place.
  • Une réservation échue est un conflit : prenez-en une nouvelle.
  • Une instance de carte indisponible ou inexistante donne lieu à un refus de validation.
  • Un transfert refusé par la salle cible est un conflit ; la marche à suivre dépend du motif.
  • Une création au-delà de la limite de salles répond par une limite de débit ou par un conflit, selon la limite atteinte.

Limites

Les chiffres viendront avec le chapitre sur les limites de la plateforme ; ce qui se passe à chaque plafond, lui, est déjà fixé.

  • La capacité d’une salle : l’arrivée est refusée comme conflit, les places réservées comptant comme occupées.
  • Salles par projet : la création est refusée comme conflit.
  • Salles par acteur : la création est refusée, et les salles existantes ne sont jamais démantelées pour faire de la place.
  • Le rythme de création de salles : une limite de débit, avec une échéance.
  • Le délai d’inactivité d’un participant : un départ forcé, avec un événement et un motif déclaré.
  • Le TTL d’une salle vide : démantèlement avec un événement ; on peut le désactiver pour le type de zone persistante.
  • L’échéance d’une réservation de place : libération avec un événement.
  • Salles sur une même instance de carte : la création sur une instance occupée est refusée, sauf si le type a déclaré une occupation partagée.
  • La taille de la charge utile d’un événement de salle : la publication est refusée avant l’envoi, jamais tronquée.