Aller au contenu principal
Le modèle de votre jeu

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

Entity

Une entité est une déclaration de schéma dotée d’aspects vivants : données 0..*, états 0..*, RPC 0..*, événements 0..*, hooks et historique des modifications. Les cartes lient les obstacles à des entités, les collisions se lient à un aspect de transformation, les statistiques ne sont qu’un preset, et les objets du monde sont un preset plus une machine à états.

Le module Entity se monte à la racine : room.Entity<Door>(id) et playserv.Entities<KeyDef>() se trouvent donc directement à la racine, et non derrière un espace de noms. Il repose sur trois primitives — les événements, les RPC et les données — et sur rien d’autre. Les modules Collision, Locomotion et Prediction & Lag Comp se placent au-dessus : chacun se lie à un seul aspect, pas à l’entité entière. C’est pourquoi un contact peut déclencher une transition sans que le module de collisions sache quoi que ce soit des permissions.

Quand l’utiliser

  • Un objet du monde a besoin d’un comportement, pas seulement de champs : machines à états, RPC soumises à permission et événements dans une même déclaration.
  • Portes, pièges, objets à ramasser : les transitions doivent se déclencher sur des événements client, des contacts de collision ou des seuils de statistique, sans code dans la salle (room).
  • Vous voulez créer vos objets de jeu en une ligne : appliquez des presets comme world-objects, ou dérivez-en.
  • Pour trancher un litige, il faut l’état exact du monde au moment du tir : lisez une instance à un sim_time passé, dans la fenêtre déclarée.
  • Inutile si la chose n’a pas d’identité propre. Une valeur qui ne vit jamais qu’à l’intérieur d’autre chose, comme le texte de la plaque d’une porte, est un champ dans un aspect, pas une entité à part entière. Tout ce que l’on adresse est une entité : Data & Subscriptions fournit la mécanique sous-jacente, et aucun accès direct aux tables ne la contourne.

Qui fait quoi

ActeurCe qu’il fait sur cette page
schema-authordéclare les entités, les aspects, les machines à états et les presets
tout acteurinterroge, s’abonne, appelle les RPC des entités, lit l’état

En bref

La porte du donjon : deux aspects avec leur propre politique, une machine gardée par des conditions, un événement déclaré et une RPC qui nomme le droit dont elle a besoin
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}
@Aspect('info', { read: 'any' })                            // rarely changes, everyone reads it
export class Info { name = ''; }

@Aspect('motion', { hz: 20, read: 'any', write: 'fn' })     // 20 updates a second while it swings
export class Motion { openRatio = 0; jammed = false; }

@Machine('gate')
export class Gate {
  @State({ initial: true }) @Transition('open_requested', { to: 'opening' }) closed: State;
  @State() @AfterSeconds(1.2, { to: 'open' }) opening: State;
  @State() @Transition('close_requested', { to: 'closed' }) open: State;
  @State('open.blocked') @Transition('cleared', { to: 'open', guard: '!motion.jammed' }) blocked: State;
}

@Entity('key-def', { persistence: Persistence.Persistent })  // authored content: key.bronze, key.gold
export class KeyDef {
  @Key() key = '';
  @Aspect() info: Info;
}

@Entity('door', { persistence: Persistence.Runtime })
export class Door {
  @Aspect() info: Info;
  @Aspect() motion: Motion;
  @Machine() gate: Gate;
  @Ref() needs: Ref<KeyDef>;                                 // holds the id, never the key
  @Event('locked', { clock: Clock.SimTime }) locked: Event;  // reaches whoever sees the door

  @EntityRpc({ requires: Entity.permissions.execute, rows: 'caller in entity.room' })
  requestOpen(caller: Actor) {
    if (caller.inventory.has(this.needs)) this.gate.fire('open_requested');
    else this.locked.send();
  }
}
@aspect("info", read="any")                                  # rarely changes, everyone reads it
class Info:
    name: str = ""

@aspect("motion", hz=20, read="any", write="fn")             # 20 updates a second while it swings
class Motion:
    open_ratio: float = 0.0
    jammed: bool = False

@machine("gate")
class Gate:
    closed  = state(initial=True, on="open_requested", to="opening")
    opening = state(after_seconds=1.2, to="open")
    open    = state(on="close_requested", to="closed")
    blocked = state("open.blocked", on="cleared", to="open", guard="!motion.jammed")

@entity("key-def", persistence=Persistence.PERSISTENT)       # authored content: key.bronze, key.gold
class KeyDef:
    key: str = key()
    info: Info = aspect()

@entity("door", persistence=Persistence.RUNTIME)
class Door:
    info: Info = aspect()
    motion: Motion = aspect()
    gate: Gate = machine()
    needs: Ref[KeyDef] = ref()                               # holds the id, never the key
    locked = event("locked", clock=Clock.SIM_TIME)           # reaches whoever sees the door

    @entity_rpc(requires=entity.permissions.execute, rows="caller in entity.room")
    def request_open(self, caller: Actor):
        if caller.inventory.has(self.needs):
            self.gate.fire("open_requested")
        else:
            self.locked.send()
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.

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT()
struct FInfo { GENERATED_BODY() UPROPERTY() FString Name; };

USTRUCT()
struct FMotion { GENERATED_BODY() UPROPERTY() float OpenRatio; UPROPERTY() bool bJammed; };

USTRUCT(PSMachine = (Name = "gate"))
struct FGate
{
    GENERATED_BODY()

    UPROPERTY(PSState = (Name = "closed", Initial = "true"),
              PSTransition = (On = "open_requested", To = "opening"))               FPSState Closed;
    UPROPERTY(PSState = "opening", PSAfterSeconds = (Seconds = "1.2", To = "open")) FPSState Opening;
    UPROPERTY(PSState = "open", PSTransition = (On = "close_requested", To = "closed")) FPSState Open;
    UPROPERTY(PSState = (Name = "open.blocked"),
              PSTransition = (On = "cleared", To = "open", Guard = "!motion.jammed")) FPSState Blocked;
};

USTRUCT(PSEvent = (Name = "locked", Clock = "SimTime"))
struct FLocked { GENERATED_BODY() };                       // reaches whoever sees the door

UCLASS(PSEntity = (Name = "key-def", Persistence = "Persistent"))
class UKeyDef : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSKey)                                    FString Key;
    UPROPERTY(PSAspect = (Name = "info", Read = "any")) FInfo Info;
};

UCLASS(PSEntity = (Name = "door", Persistence = "Runtime"))
class UDoor : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSAspect = (Name = "info", Read = "any"))                          FInfo Info;
    UPROPERTY(PSAspect = (Name = "motion", Hz = 20, Read = "any", Write = "fn")) FMotion Motion;

    UPROPERTY(PSMachine = "gate")
    FGate Gate;

    UPROPERTY(PSRef = "key-def") TPSRef<UKeyDef> Needs;    // holds the id, never the key

    UFUNCTION(PSRpc = (Requires = "Entity.Execute", Rows = "caller in entity.room"))
    void RequestOpen();
};
Unity C# is the same C# API here — les mêmes attributs C# compilent dans Unity (version de référence 2021.3, aucun C# plus récent requis) — les déclarations sont publiées dans le même modèle
[Aspect("info", Read = "any")]                            // rarely changes, everyone reads it
public class Info { public string Name; }

[Aspect("motion", Hz = 20, Read = "any", Write = "fn")]   // 20 updates a second while it swings
public class Motion { public float OpenRatio; public bool Jammed; }

[Machine("gate")]
public class Gate
{
    [State(Initial = true), Transition("open_requested", to: "opening")] public State Closed;
    [State, AfterSeconds(1.2f, to: "open")]                              public State Opening;
    [State, Transition("close_requested", to: "closed")]                 public State Open;
    [State("open.blocked"), Transition("cleared", to: "open", Guard = "!motion.jammed")] public State Blocked;
}

[Entity("key-def", Persistence = Persistence.Persistent)]  // authored content: key.bronze, key.gold
public class KeyDef
{
    [Key]    public string Key;
    [Aspect] public Info Info;
}

[Entity("door", Persistence = Persistence.Runtime)]
public class Door
{
    [Aspect]  public Info Info;
    [Aspect]  public Motion Motion;
    [Machine] public Gate Gate;
    [Ref]     public Ref<KeyDef> Needs;                            // holds the id, never the key
    [Event("locked", Clock = Clock.SimTime)] public Event Locked;  // reaches whoever sees the door

    [EntityRpc(Requires = Entity.Permissions.Execute, Rows = "caller in entity.room")]
    public void RequestOpen(Actor caller)
    {
        if (caller.Inventory.Has(Needs)) Gate.Fire("open_requested");
        else Locked.Send();
    }
}

L’unité déclarée, c’est l’aspect, pas le champ : motion a sa propre cadence et son propre masque, info en a d’autres, et chaque champ appartient à un seul d’entre eux. C’est ce qui permet à un preset d’ajouter tout un groupe d’un coup, et à Collision de se lier au seul aspect qui porte une transformation, sans rien voir d’autre de l’entité.

Trois règles régissent la machine de cet exemple :

  • Un nom à point imbrique sur un niveau. open.blocked se rattache de lui-même à open : la transition close_requested déclarée sur open s’applique donc à l’intérieur sans qu’on la répète. Tant que la machine est dans open.blocked, elle est dans open — un test d’état sur open renvoie vrai, et OnEntered("open"), déclenché à l’entrée, ne se déclenche pas une seconde fois pour le sous-état.
  • Un minuteur déclaré sur un état ne tourne que tant que cet état est actif. Quitter opening annule son AfterSeconds, et y revenir en démarre un nouveau.
  • Une condition de transition (guard) est un prédicat déclaré sur les champs de l’entité elle-même, dans le même langage qu’un prédicat de lignes d’accès. Une logique qui demande du code relève d’un hook, pas d’une condition de transition.

Les chemins de champs suivent l’orthographe du modèle publié. Les prédicats et les chemins de requête nomment les champs tels que la déclaration les a publiés — motion.jammed, gate.state, info.name — quelle que soit leur graphie locale dans chaque binding.

Qui peut appeler la RPC. Une RPC d’entité nomme le droit dont elle a besoin comme toute opération : une permission atomique (entity × execute) plus un prédicat de lignes qui indique quelles instances elle couvre (les deux sont décrits sur Access & Roles).

Dans la déclarationCe que cela signifie
caller in entity.roomtout acteur présent dans la salle où se trouve la porte, quel que soit le build qu’il exécute. La proximité n’entre pas en jeu : la distance à laquelle il faut se tenir pour recevoir les deltas de la porte relève d’une règle de visibilité dans la politique de synchronisation de l’aspect — une question de bande passante, pas de permission, et élargir une vue n’élargit jamais un droit
Actorl’identité de l’appelant, le même objet que celui renvoyé par whoami
caller.Inventoryle handle de Inventory pour ce joueur, disponible partout où ce module est monté

C’est playserv push qui rend une déclaration effective. Cette étape est décrite dans Le schéma dans le code : elle compare vos déclarations au modèle déployé, transmet la révision qui a servi au diff, et refuse au lieu d’écraser si le schéma déployé a changé entre-temps. Une nouvelle publication qui casserait des instances déjà actives passe par proposer → planifier → appliquer : vous pouvez lire le plan avant que quoi que ce soit ne change.

Côté client, l’entité est l’API :

La porte comme API : se connecter, rejoindre la salle, appeler la RPC, traiter les deux issues — le carillon et le signal locked
var playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));
const playserv = await PlayServ.connect(projectKey);
const room = await playserv.rooms.join(seat);  // a seat from Matchmaking, or a room you found

const door = room.entity<Door>(doorId);        // a typed Ref — passable to any RPC as-is
await door.requestOpen();
door.gate.onEntered('open', () => playChime());
door.locked.on(() => hud.flash('Locked — the bronze key opens it'));
playserv = await PlayServ.connect(project_key)
room = await playserv.rooms.join(seat)     # a seat from Matchmaking, or a room you found

door = room.entity(Door, door_id)          # a typed Ref — passable to any RPC as-is
await door.request_open()
door.gate.on_entered("open", lambda: play_chime())
door.locked.on(lambda: hud.flash("Locked — the bronze key opens it"))
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.

FPlayServClient::Connect(ProjectKey,
    TPSOnResult<FPlayServClient*>::CreateWeakLambda(this, [this](const TPSResult<FPlayServClient*>& ConnectResult)
    {
        if (!ConnectResult.HasValue()) { return; }
        // a seat from Matchmaking, or a room you found
        ConnectResult.Value()->Rooms->Join(Seat,
            TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
            {
                if (!JoinResult.HasValue()) { return; }
                OnJoined(JoinResult.Value());
            }));
    }));

// in OnJoined(FPSRoom* Room): a typed handle — every declared member generated, passable to any RPC
Room->Entities->Of<UDoor>()->Get(DoorId,
    TPSOnResult<UDoor*>::CreateWeakLambda(this, [this](const TPSResult<UDoor*>& DoorResult)
    {
        if (!DoorResult.HasValue()) { return; }
        UDoor* Door = DoorResult.Value();
        Door->Call->RequestOpen();
        TPSSubscription OpenChime  = Door->Gate->Subscribe->Entered(PSKeys::States::Open, [this]() { PlayChime(); });
        TPSSubscription LockAlerts = Door->Subscribe->Locked([this]() { Hud->Flash(TEXT("Locked — the bronze key opens it")); });
    }));
// 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 playserv = await PlayServ.Connect(projectKey);
var room = await playserv.Rooms.Join(seat);   // a seat from Matchmaking, or a room you found

var door = room.Entity<Door>(doorId);         // a typed Ref — passable to any RPC as-is
await door.RequestOpen();
door.Gate.OnEntered("open", () => PlayChime());
door.Locked.On(() => Hud.Flash("Locked — the bronze key opens it"));

Le signal locked est un événement propre à la porte : sa cible est celle de la déclaration — cette instance. Tous les abonnés à la porte le reçoivent donc, et aucune liste de destinataires n’accompagne l’envoi.

Le modèle

Un char, une porte, une barre de statistique et une quête sont tous des entités. Seuls les aspects qu’elles portent les distinguent, et c’est ce qui permet à tous les autres modules de s’appuyer sur celui-ci.

Ce que déclare une entité.

DéclareCe que c’est
aspectun groupe de champs nommé, déclaré d’un bloc, avec sa propre politique de synchronisation et son propre masque d’accès. Une entité en porte plusieurs, et aucun champ n’appartient à deux aspects
machine à étatsdes états imbricables sur un niveau, des transitions et des conditions de transition ; plusieurs par entité
triggerce qui déclenche une transition — les quatre sources sont décrites plus bas
RPC d'entitéun verbe sur l’entité, déclaré dans la vue avec la permission atomique dont il a besoin
événement d'entitéun signal émis par l’entité, livré à quiconque s’abonne à cette instance
hookavant et après, sur les opérations de données et sur les transitions, déployés comme fonctions cloud. Extensibility déclare leur ordre, la forme du verdict et l’effet d’un échec
suivi d'historiqueindique si la vue conserve ou non la fenêtre d’historique instantané
refun lien vers une autre entité qui contient son id et jamais sa key : renommer une clé ne casse donc jamais un lien. Include la charge avec la page

Ce qui déclenche une transition. Aucune des quatre sources n’est du code qui tourne dans une salle.

SourceComment elle déclenche
événement client ou RPCn’importe lequel parmi ceux déclarés — RequestOpen ci-dessus déclenche open_requested
collisionun contact ou l’entrée dans un volume de déclenchement — pièges, plaques de pression — via l’aspect auquel se lie Collision
seuil de donnéesdéclaré sur une statistique, 0 HP → death, et appliqué par l’ordre des hooks plutôt que par du code dans une salle
tempsAfterSeconds sur un état est un déclencheur déclaré, pas une coroutine : il suit l’horloge de simulation de la salle, avance avec sim_time et s’arrête quand la salle ne simule pas. Supprimer l’instance met fin à ses machines et à leurs minuteurs en attente

Ce qu’une sélection peut faire.

AxeCe qui est admis
filter et sortuniquement les champs déclarés — il n’existe pas de handle de table, et une sélection s’adresse aux entités, dans le périmètre d’une salle ou du projet
includeune ref déclarée, chargée avec la page
pagingpar curseur opaque : ni un décalage, ni un identifiant de ligne, et sa signification ne survit pas à un changement de version. Renvoyez-le tel quel, ne l’analysez jamais
accessles prédicats s’appliquent avant la pagination : une page n’a donc jamais de trous à la place des lignes masquées
lives’abonner à une sélection la maintient à jour : des membres y entrent et en sortent à mesure que leurs données changent
Interroger, filtrer, trier, paginer par curseur — et s’abonner à la sélection elle-même
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
const shut = await room.entities<Door>()
  .where((d) => d.gate.state === 'closed')
  .include((d) => d.needs)
  .orderBy((d) => d.info.name)
  .page(20)
  .query();

// live selection: fires as doors swing open and shut
room.entities<Door>().where((d) => d.gate.state === 'open').subscribe((open) => minimap.mark(open));

// project-wide, outside any room: the key catalogue, page by page
const keys = await playserv.entities<KeyDef>().orderBy((k) => k.info.name).page(50).query();
const more = await playserv.entities<KeyDef>().orderBy((k) => k.info.name)
  .page(50, { after: keys.cursor }).query();
# in this room: doors still shut, by name, first page of 20 — with the key each one needs
shut = await (room.entities(Door)
    .where("gate.state", "closed")
    .include("needs")
    .order_by("info.name")
    .page(20)
    .query())

# live selection: fires as doors swing open and shut
room.entities(Door).where("gate.state", "open").subscribe(lambda open: minimap.mark(open))

# project-wide, outside any room: the key catalogue, page by page
keys = await playserv.entities(KeyDef).order_by("info.name").page(50).query()
more = await playserv.entities(KeyDef).order_by("info.name").page(50, after=keys.cursor).query()
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.

// in this room: doors still shut, by name, first page of 20 — with the key each one needs
Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
    .Include(PSFields::Door::Needs)
    .OrderBy(PSFields::Door::Info::Name)
    .Page(20)
    .Then(TPSOnResult<TPSPage<UDoor>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UDoor>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const TPSPage<UDoor>& ShutDoors = Result.Value();
        Minimap->MarkShut(ShutDoors.Rows);

        // the next page rides the cursor this one returned
        Room->Entities->Of<UDoor>()->Select()
            .Where(PSFields::Door::Gate::State == PSKeys::States::Closed)
            .Page(20, ShutDoors.Cursor)
            .Then(OnMoreShutDoors);
    }));

// live selection: fires as doors swing open and shut
TPSSubscription OpenDoors = Room->Entities->Of<UDoor>()->Select()
    .Where(PSFields::Door::Gate::State == PSKeys::States::Open)
    .Subscribe([this](const TArray<UDoor*>& Open) { Minimap->Mark(Open); });

// project-wide, outside any room: the key catalogue
Client->Entities->Of<UKeyDef>()->Select()
    .OrderBy(PSFields::KeyDef::Info::Name)
    .Page(50)
    .Then(TPSOnResult<TPSPage<UKeyDef>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<UKeyDef>>& KeyPage)
    {
        if (!KeyPage.HasValue()) { return; }
        Catalogue->Show(KeyPage.Value().Rows);
    }));
// 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
// in this room: doors still shut, by name, first page of 20 — with the key each one needs
var shut = await room.Entities<Door>()
    .Where(d => d.Gate.State == "closed")
    .Include(d => d.Needs)
    .OrderBy(d => d.Info.Name)
    .Page(20)
    .Query();

// live selection: fires as doors swing open and shut
room.Entities<Door>().Where(d => d.Gate.State == "open").Subscribe(open => Minimap.Mark(open));

// project-wide, outside any room: the key catalogue, page by page
var keys = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50).Query();
var more = await playserv.Entities<KeyDef>().OrderBy(k => k.Info.Name).Page(50, after: keys.Cursor).Query();

Ce qui vaut pour toute entité.

ToujoursCe que c’est
une sélectionest un ensemble d’entités, pas un groupe : les membres d’un groupe sont des acteurs, réunis pour qu’un même signal les atteigne tous, alors qu’une sélection est une lecture qui se trouve rester à jour
l'historiquese limite à la fenêtre instantanée : les états récents indexés par sim_time, bornés par une profondeur déclarée. Un historique à branches — chronologies alternatives, annulation, rejeu d’un match entier — sort du périmètre : il faudrait garantir des valeurs flottantes reproductibles, ce que les règles de typage ne garantissent pas
une méthode déclarée sans implémentationest un état abouti, pas une configuration à moitié faite. L’appeler renvoie un verdict portant la raison lisible par machine "no implementation" : ni un refus, ni un succès au résultat vide
un nom jamais déclarédonne une autre issue qu’un nom déclaré sans implémentation : le premier est un refus de validation, le second un verdict, et le code sait les distinguer
un appel non implémenténe disparaît pas — le studio peut observer que quelqu’un l’a invoqué. La forme de cette observation ne fait volontairement pas partie du contrat : appuyez-vous sur le fait qu’elle est observable, pas sur une ligne de log
créer une instanceexige la permission atomique entity × write : une fonction cloud, un serveur dédié et un client maître la détiennent par défaut, un simple client seulement si un rôle la lui accorde — dans tous les bindings, pas seulement dans Unreal

Une modification n’est atomique qu’au sein d’une seule entité

Quand un même appelant modifie deux entités, par exemple en débitant un portefeuille et en ajoutant l’objet, on peut observer l’opération à moitié appliquée. Deux changements qui doivent apparaître ensemble appartiennent à une seule instance. Un hook ne peut pas y suppléer : il encadre une modification, pas deux.

Presets. Un preset est un ensemble nommé d’aspects, de machines, de hooks et de limites appliqué à une vue. Il n’ajoute aucun concept : tout ce qu’il apporte, vous pourriez le déclarer à la main. Cinq sont fournis — stats, abilities, projectiles, drops, world-objects — et Entity Presets les décrit chacun en détail. Un studio en dérive les siens, dans le code ou dans le panneau — Crate, c’est world-objects plus stats :

Dériver Crate de deux presets fournis, puis créer chaque caisse en une ligne et la régler à 250 PV
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);
// derived once, in the schema
@Entity('crate', { persistence: Persistence.Runtime, presets: [Preset.WorldObjects, Preset.Stats] })
export class Crate { @Stat({ max: 100, atMin: 'broken' }) hp: Stat; }

// then one line per crate, on the room host
const crate = await room.create<Crate>({ at: pos, tune: (c) => { c.hp.max = 250; } });
# derived once, in the schema
@entity("crate", persistence=Persistence.RUNTIME, presets=[Preset.WORLD_OBJECTS, Preset.STATS])
class Crate:
    hp = stat(max=100, at_min="broken")

# then one line per crate, on the room host
crate = await room.create(Crate, at=pos, tune={"hp.max": 250})
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.

// declarations compile into the same pushed model — playserv push from the UE project or CI
UCLASS(PSEntity = (Name = "crate", Persistence = "Runtime", Presets = "world-objects, stats"))
class UCrate : public UObject
{
    GENERATED_BODY()

    UPROPERTY(PSStat = (Max = 100, AtMin = "broken")) FPSStat Hp;
};

// then one line per crate, on the room host
Room->Entities->Of<UCrate>()->Create(FPSIdempotencyKey(CrateId),
    [SpawnPosition](UCrate& Crate) { Crate.Position = SpawnPosition; });   // Position — from the world-objects preset
// 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 — la même déclaration C# ; la création relève de l’hôte de la salle, et un client Unity voit la caisse apparaître
// derived once, in the schema
[Entity("crate", Persistence = Persistence.Runtime, Presets = new[] { Preset.WorldObjects, Preset.Stats })]
public class Crate { [Stat(Max = 100, AtMin = "broken")] public Stat Hp; }

// then one line per crate, on the room host
var crate = await room.Create<Crate>(at: pos, tune: c => c.Hp.Max = 250);

Erreurs

  • Une instance inexistante et une instance masquée par un prédicat répondent toutes deux not found — un refus ne révèle donc jamais à l’appelant qu’une chose existe sans lui appartenir.
  • Un champ non déclaré, champs imbriqués compris, et un champ obligatoire sans valeur donnent des refus de validation qui nomment le champ.
  • Une transition que la machine ne déclare pas est refusée avec invalid_state_transition, jamais ignorée en silence. Forcer une machine au-delà de ses conditions de transition est une autre opération, avec une autre permission atomique — administer, qu’aucune clé client ne détient par défaut.
  • Un écart de version est un échec de précondition : relisez, puis décidez à nouveau.
  • Une clé déjà prise est un conflit.
  • Une écriture au nom d’un joueur qui ne nomme pas ce joueur est un refus de validation, plutôt qu’une écriture attribuée à personne.
  • Sans permission, la réponse est forbidden, en distinguant lecture et écriture.

Limites

Les valeurs se fixent par projet ; ce qui se passe à chaque limite est déjà arrêté.

LimiteÀ la limite
aspects par vue · machines par vue · profondeur d’imbrication dans un aspectla déclaration est rejetée au playserv push, jamais tronquée en silence
taille de l’instance stockéel’écriture est refusée comme conflit, avec le champ et la taille mesurée ; l’approche de la limite est observable avant le refus
taille de page d’une sélectionla page est coupée au plafond et « il y a une suite » reste vrai — vous ne recevez jamais une page courte qui aurait l’air d’être la dernière
fenêtre d’historique instantanéune lecture hors de la fenêtre est refusée, et non servie avec la valeur la plus proche
fréquence des modifications d’une instanceun refus pour limite de débit, qui indique combien de temps attendre