Not translated yet — showing English.
Messaging
Rooms, groups, players: one addressing model for chat and notifications. Messages arrive in a conversation; conversations are channels with history, moderation and out-of-band delivery on top.
When to use it
- Players talk (room chat, guild channels, DMs) over the addressing you already have: room, group, player.
- Offline players must still hear: templated, schedulable notifications deliver out-of-band by push.
- Moderation must run before delivery: a pre-send hook filters or rejects, and mute/block is platform-enforced everywhere.
- Returning players need catch-up:
History(take: 50)pages the conversation on next launch. - Skip it when the payload is game state, not conversation. Synced fields in Data and core channels already fan that out.
Who does what
| Actor | On this page |
|---|---|
player | sends and receives messages; reads history; mutes or blocks |
moderator | filters, redacts and bans terms |
backend-service | sends or schedules templated notifications |
At a glance
Send per addressing target — room, guild, direct — plus subscribe and history// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");
playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');
playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")
playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
{
if (!Result.HasValue()) { return; }
FPSConversation* RoomChat = Result.Value();
RoomChat->Send->Text({ TEXT("gg!") });
// history pages under the same node that carries the messages
RoomChat->Messages->Select().Page(50).Then(
TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
{
if (!History.HasValue()) { return; }
Chat->Show(History.Value().Rows);
}));
}));
// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);
// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");
playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);A structured message is a declared event, and the conversation carries it by name, with no payload class to construct at the call site:
RallyCall declared once; the guild conversation sends it by name[Message("rallyCall")]
public class RallyCall
{
public Vector3 At;
public string Note = "";
}
var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");@Message('rallyCall')
export class RallyCall {
at!: Vector3;
note = '';
}
const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });@message("rallyCall")
class RallyCall:
at: Vector3
note: str = ""
guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
GENERATED_BODY()
UPROPERTY() FVector At;
UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
{
if (!Result.HasValue()) { return; }
Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.[Message("rallyCall")]
public class RallyCall
{
public Vector3 At;
public string Note = "";
}
var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");The declared message arrives typed in the same subscription, so a client that knows RallyCall gets fields rather than a blob.
Notifications are out-of-band, templated and schedulable, and they are sent from fn or adm authority, never from a player session:
raid-starts notification, sent from a cloud function and delivered out-of-band// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
args: new { at = start });// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
{ args: { at: start } });# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
args={"at": start})Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
The call exists in Unreal. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unreal receives the delivered notification. See Access & Roles.
The call exists in Unity. Sending a notification needs fn/adm authority, so the platform refuses it on a player session whatever binding makes the call; Unity receives the delivered notification. See Access & Roles.
Moderation as hooks, same contract as everywhere:
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);export const filter = before(Messaging.send, (m: OutgoingMessage) =>
Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the resulting events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the resulting events.
The model
What a conversation type declares.
| Declares | What it is |
|---|---|
the group | its roster — a participant is an actor, exactly as in groups |
binding to a lifetime | optionally another entity's, so a room chat disappears with its room |
Where the line runs between the envelope and the payload.
| Part | Whose it is |
|---|---|
envelope | the platform's: the author, the conversation, the moment by the declared clock |
payload | the studio's, declared as a message type with typed fields, and appearing as its own send surface rather than as an untyped bag |
Three things this module owns that a plain event does not: the order within a conversation, the retention period, and the subject of moderation. None of them is in events, which is why chat is not "an event with history".
The three machines.
| Of | States |
|---|---|
| a conversation | created → active → closed |
| a message | sent → published | rejected by the filter, and then edited or deleted, observably |
| a notification | created → queued → delivered | expired |
What is true of every message.
| Always | What it is |
|---|---|
order within a conversation | is stable and declared. Order between conversations is not promised |
editing and deleting | are observable: a message never vanishes silently, so a client's history can't diverge from the server's unnoticed |
history | is the messages themselves: with a declared retention period, read in pages by cursor from a position |
retention outlives the complaint window | the period is no shorter than the time allowed to handle a complaint: a complaint arrives after the message, and a message that no longer exists leaves nothing to handle |
read state | is a position, not a flag: one position per actor per conversation, and marking read is monotonic — the position never decreases, so a repeat call cannot undo progress. The unread count is a derivative of that position rather than a counter of its own |
sending | is idempotent by key: two calls are two lines of dialogue, so the key is what makes a retry safe |
blocking | is a delivery predicate, not a refusal to send: the sender is not told, because a refusal would reveal the block. The state itself lives in social |
the sender composes the payload | the platform does not read the recipient's data to fill in your text. The recipient's locale may be a declared context claim that travels to the extension point, so substitution and translation are the hook's work — the one place that knows both the recipient and their locale |
the delivery route | is not part of the contract: push, in-app, or something else is a routing decision, not a promise |
delivery | is observable within declared bounds: "queued" always; anything beyond that as far as the route can report |
Each extension point names the type it hands the hook: the outgoing message before publication, the published message after. A filter may correct the content it was handed — masking a word is a correction — but never the sender or the conversation.
Errors
- A conversation that does not exist or is hidden, and an actor who is not a participant, both answer not found — so a refusal never reveals a conversation you are not in.
- A closed conversation is a conflict.
- No permission to write in this type answers forbidden, and repeating is pointless.
- Rejection by the filter is a verdict, not a refusal. The call was performed, the content was considered, the decision is negative and the reason is a declared value — which is why it is distinguishable from a refusal by permissions, and why what to do next depends on the reason.
- The filter being unavailable answers unavailable and is worth retrying with backoff — but nothing was published in the meantime.
- An undeclared message type for this conversation, and an oversized message, are validation refusals; content is never truncated silently.
- The send rate exceeded answers in the rate-limit category with a deadline.
- Editing another's message answers forbidden.
- A notification past its expiry is a conflict: send a new one.
Limits
The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.
- Message size, and attachments with their size: the send is refused as a validation failure, never truncated. The files themselves are in Files & UGC.
- The send rate per actor: a rate limit with a deadline.
- The depth of history: past the period a message is evicted from retention with an event, rather than disappearing quietly.
- Conversations per actor: joining another is refused as a conflict.
- Notifications queued per actor: a new one is refused, and eviction is forbidden: a silently discarded notification would look like one that was never sent.
- A notification's expiry:
expired, with an event. - Participants in a conversation is groups' limit, and blocks per actor is social's — neither is restated here.