Activity Feeds (Scope Walls)
A recent-activity wall per scope (an area, a group, a server): game code appends typed entries - a kind, the actor, a few numbers and a small opaque payload - and players read them newest first, a page at a time, with actor names and a total, or poll cheaply with a since-cursor. Each scope keeps a capped number of entries, oldest first out (an option, or the game's IActivityPolicy per scope), and who may read a scope is the same policy. Nothing is pushed; durable across restarts.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayActivity(); and it wins.
IActivityPolicy game SPI
Who may read a scope's wall, and how long it is - the game's rules (a region's members, a guild's members). Nothing registered = everyone signed in reads everything, capped by MaxEntriesPerScope.
-
int? MaxEntries(string scope)Most entries the scope keeps (null = MaxEntriesPerScope).
-
bool MayRead(long accountId, string scope)May this account read the scope's activity? Default: yes.
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IActivityClock provider
The clock entries are stamped by (a seam so tests control time).
-
long UtcNowMs { get; }Now, unix ms UTC.
IActivityStore provider
Where walls are kept. The default is in memory; persistence composes a document-backed one.
-
IReadOnlyList<ActivityRecord> Load(string scope)A scope's entries, oldest first.
-
long NextId()The next entry id.
-
void Save(string scope, IReadOnlyList<ActivityRecord> entries)Replaces a scope's entries (already capped by the caller).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IActivityService
Activity walls: per-scope logs of what happened (someone joined, someone donated, an election was won), appended by game code and read by players newest first. Bounded per scope (oldest first out) and durable. Nothing is pushed: clients read when the page opens, and poll with a since-cursor.
-
long Append(string scope, ushort kind, long actorAccountId, long[] values = null, byte[] data = null)Appends an entry to a scope (no access check - the server is the author). Returns its id. Throws ArgumentException when the scope key or the payload breaks a limit - that is a game bug.
-
event Action<string, ActivityRecord> AppendedRaised after an entry is appended: (scope, entry).
-
void Clear(string scope)Removes every entry from a scope.
-
IReadOnlyList<ActivityRecord> Page(string scope, long beforeId, long sinceId, int count, out int total, out bool more)A page of a scope's entries, newest first (see ActivityPageRequest for the cursors).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
ActivityOptions
Activity tuning, bound from Crossplay:Activity.
-
int MaxDataBytes { get; set; }Largest opaque payload one entry may carry, in bytes.
-
int MaxEntriesPerScope { get; set; }Most entries one scope keeps; past it the oldest go (a policy may answer per scope).
-
int MaxScopeLength { get; set; }Longest scope key, in characters.
-
int MaxValues { get; set; }Most numbers one entry may carry.
-
int PageSize { get; set; }Entries per page (and the most a request may ask for).
Wire messages 2
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
ActivityPageRequest Client -> server: a page of a scope's activity, newest first. Pass BeforeId to page back, or SinceId to get only what is new since the newest entry you hold.
ActivityPage Server -> client: a page of a scope's activity, newest first.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IActivityClient
Client SDK for Activity - per-scope activity walls. Each call is one awaitable request (Core's request/reply helper - it never throws; a timeout completes unsuccessfully). Turning an entry's kind and numbers into a line of text is the game's. Nothing is pushed: read when the wall opens, then poll with PollAsync. Pure SDK, no UI.
-
UniTask<ActivityPage> PageAsync(string scope, long beforeId = 0, int count = 0, CancellationToken ct = default)A page of a scope's entries, newest first. beforeId = the oldest id you hold to page back (0 = from the newest); count 0 = the server's page size.
-
UniTask<ActivityPage> PollAsync(string scope, long sinceId, CancellationToken ct = default)Only the entries newer than sinceId (pass the last reply's LatestId).