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
| Actor | On this page |
|---|---|
player | creates 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-owner | room seat rules ride this primitive (configured in Rooms) |
backend-service | declares group types and their rules; hooks on entry and exit |
At a glance
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 groupAttribute-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 groupA 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.
[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: ...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() };[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.
| Declares | What it is |
|---|---|
name | the type's own |
membership mode | explicit — 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 |
rule | for a dynamic group: the predicate, in the same predicate language as access predicates and transition guards. The platform recomputes it; nobody polls |
capacity | and the behaviour on reaching it |
entry rule | a predicate that may reject entry, separately from a hook that may also reject it |
lifecycle behaviour | on 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 |
lifetime | optional: once it expires the group closes with an event |
What is true of every group.
| Always | What it is |
|---|---|
member | an actor, never an entity: a set of entities is a selection over Data. A group is one mass listener |
states | created → active → closed, and closed is terminal. A group instance has a machine; the type does not declare it |
event target | emit on it and its members receive; that is what makes bulk delivery one signal rather than a loop |
a group call | is 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 |
recipients | are never enumerated by the sender: membership decides, so a sender does not have to know the composition of the audience |
intra-group roles | do not exist: a "group owner" is an actor holding a right (Access), not a rank stored in the member list |
first entry and last exit | are distinguishable from the joins and leaves in between — which is what the declared lifecycle behaviour and round initialisation hang on |
recomputation | carries 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 leave | are 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 interface | is a concrete group's, not only the type's: you address this squad |
the primitive | stays 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.
AddorRemoveon 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 = 4above); 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.