Skip to main content
Services around the game

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

ActorOn this page
playersends and receives messages; reads history; mutes or blocks
moderatorfilters, redacts and bans terms
backend-servicesends or schedules templated notifications

At a glance

One 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)
Coming soon — Go

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.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
// 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")
Coming soon — Go

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.
Unity C# is the same C# API here — the same declaration and call compile in Unity, on the 2021.3 baseline
[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:

A templated 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})
Coming soon — Go

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

A different actor calls this

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.

A different actor calls this

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:

A pre-send hook: profanity is rejected before it ever lands
[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)
Coming soon — Go

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

Runs off the engine

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.

Runs off the engine

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.

DeclaresWhat it is
the groupits roster — a participant is an actor, exactly as in groups
binding to a lifetimeoptionally another entity's, so a room chat disappears with its room

Where the line runs between the envelope and the payload.

PartWhose it is
envelopethe platform's: the author, the conversation, the moment by the declared clock
payloadthe 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.

OfStates
a conversationcreated → active → closed
a messagesent → published | rejected by the filter, and then edited or deleted, observably
a notificationcreated → queued → delivered | expired

What is true of every message.

AlwaysWhat it is
order within a conversationis stable and declared. Order between conversations is not promised
editing and deletingare observable: a message never vanishes silently, so a client's history can't diverge from the server's unnoticed
historyis the messages themselves: with a declared retention period, read in pages by cursor from a position
retention outlives the complaint windowthe 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 stateis 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
sendingis idempotent by key: two calls are two lines of dialogue, so the key is what makes a retry safe
blockingis 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 payloadthe 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 routeis not part of the contract: push, in-app, or something else is a routing decision, not a promise
deliveryis 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.