Skip to main content
Building blocks

Not translated yet — showing English.

Groups

A group is a named set of actors that receives as one. You address the group and every member hears. A room, a chat, a matchmaking pool and a send-list are the same primitive with different entry and exit rules and different lifetimes, over the same list.

When to use it

  • You need parties, squads or guilds: named sets of players with a declared capacity and, where the type declares one, a lifetime.
  • Membership should follow a declared rule the platform evaluates: new veterans fall in without a cron job or a re-evaluate call of your own.
  • You want to address many players at once: a declared event fans out with send.*, a declared RPC reaches every member and each answer comes back named.
  • You need one membership model reused as an audience: a visibility scope, a messaging conversation, a matchmaking party.
  • Skip creating one when the set is one session's members. Rooms is this primitive with room rules and already addresses them.

Who does what

ActorOn this page
playercreates groups from declared types, joins and leaves, adds or removes members, sends events, invokes fan-out RPCs; holding the administration right on a group's membership, removes members and closes it
room-ownerroom seat rules ride this primitive (configured in Rooms)
backend-servicedeclares group types and their rules; hooks on entry and exit

At a glance

A rule-declared group, a squad with a declared capacity and lifetime, send.* fan-out and an answer per member
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

A room's chat is this primitive with message semantics on top: the room declares its own group type, mounts it in the room's namespace, and lets the room's own membership decide who is in — so the chat's list and the room's list can never disagree.

A room's chat is a declared group type — the room owns entry, the chat owns delivery
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

USTRUCT(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — runs as-is in Unity against the generated types
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

Nothing about a chat is in the primitive. The audience and the send.* surface come from here; author, thread and history come from Messaging, and who may administer the membership is a right of its own (Access), held by the room.

The model

What a group type declares.

DeclaresWhat it is
namethe type's own
membership modeexplicit — a member is added and removed by an action; or dynamic — membership is derived from a rule, and whoever satisfies the predicate is a member. A group is one of the two, never both
rulefor a dynamic group: the predicate, in the same predicate language as access predicates and transition guards. The platform recomputes it; nobody polls
capacityand the behaviour on reaching it
entry rulea predicate that may reject entry, separately from a hook that may also reject it
lifecycle behaviouron the first entry — created on first entry or created in advance; and on the last exit — closed on last exit or kept while empty. Declared, never inferred from observation
lifetimeoptional: once it expires the group closes with an event

What is true of every group.

AlwaysWhat it is
memberan actor, never an entity: a set of entities is a selection over Data. A group is one mass listener
statescreated → active → closed, and closed is terminal. A group instance has a machine; the type does not declare it
event targetemit on it and its members receive; that is what makes bulk delivery one signal rather than a loop
a group callis N calls, not one, because an RPC has exactly one logical handler. The group supplies the addressing, and each outcome arrives bound to the member it came from
recipientsare never enumerated by the sender: membership decides, so a sender does not have to know the composition of the audience
intra-group rolesdo not exist: a "group owner" is an actor holding a right (Access), not a rank stored in the member list
first entry and last exitare distinguishable from the joins and leaves in between — which is what the declared lifecycle behaviour and round initialisation hang on
recomputationcarries the delta: a rule-declared group's composition event says who came in and who dropped out, never the whole list. The whole list is a read, so a subscriber that only wants the change never pays for the roster
join and leaveare idempotent: a reconnecting client repeats its join, gets the same membership and no error — client code never has to tell "I am already in" from "I am not allowed in"
the interfaceis a concrete group's, not only the type's: you address this squad
the primitivestays empty: entry rules, round initialisation on the first member and event interception belong to the modules built on it, such as rooms, messaging and matchmaking

A group call can partly fail

A member that failed, timed out or refused is its own answer, carrying its Problem, beside the members that answered. Handle each answer; a partial success is never returned as a full one.

Errors

  • A rule that says no and a hook that says no are different answers. A false entry rule reads "entry impossible"; a hook rejection carries the hook's own reason and code. An entry hook that cannot be reached refuses the entry — the check fails closed rather than waving the actor through.
  • Dynamic membership refuses hand edits. Add or Remove on a rule-declared group is a validation refusal: the predicate is the only thing that moves that list, and the platform recomputes it when the data behind it changes.
  • Four rights, none implying another — enter, administer membership, publish to the group, read the composition (Access). An actor missing one gets a refusal, never a silent no-op; a group hidden from it by a visibility predicate answers "not found" instead, and a member always sees its own membership even when the composition is closed to it.

Limits

  • Full is a conflict, not a matter of right. At capacity + 1 the entry is rejected as a conflict, and the same call succeeds once a seat frees. The capacity itself is on the type (Capacity = 4 above); how many groups a project and a single actor may hold is set with the platform limits chapter.
  • An oversized group call is refused whole, before anything is sent — a fan-out is never half-delivered, so no caller has to detect that case. The size ceiling arrives with the platform limits chapter.