Seasons, Group Data, Scheduled Events & Matchmaking Polling¶
The four things almost every live game re-implements by hand on top of a PlayFab wrapper. They ship inside the existing managers — no new feature toggles, nothing to enable beyond the feature the API already belongs to.
| I want to… | Call | Feature toggle |
|---|---|---|
| Show last season's leaderboard | PlayFabHub.Leaderboards.GetPreviousSeasonLeaderboard(…) |
leaderboards |
| Store a club activity feed / shared goals | PlayFabHub.Social.UpdateGroupData(…) |
social |
| Show "Double XP weekend" while it is live | PlayFabHub.LiveOps.GetActiveEvents(…) |
liveOps + playerData |
| Search for a match with a Cancel button | PlayFabHub.Multiplayer.FindMatch(…) |
multiplayer |
Fail a CloudScript call on { "error": … } |
PlayFabHub.CloudScript.ExecuteChecked<T>(…) |
cloudScript |
| Read a typed remote-config blob | PlayFabHub.Data.GetTitleDataObject<T>(…) |
playerData |
1. Seasons — statistic versions¶
Every PlayFab statistic carries a monotonically increasing version. Rolling the version is the seasonal reset: the old board is archived (and stays readable forever) and every player's value restarts at zero.
Reading¶
var lb = PlayFabHub.Leaderboards;
// Which season are we in?
lb.GetCurrentStatisticVersion("Trophies", r =>
{
if (r.Ok) Debug.Log($"Season {r.Data.Version}, started {r.Data.ActivationTime:u}");
});
// Last season's final top 25 — resolves the previous version, then reads it.
lb.GetPreviousSeasonLeaderboard("Trophies", 0, 25, r => { /* r.Data.Leaderboard */ });
// Or drive it yourself from the full history.
lb.GetStatisticVersions("Trophies", r =>
{
var active = LeaderboardManager.ActiveVersion(r.Data.StatisticVersions);
var previous = LeaderboardManager.PreviousVersion(r.Data.StatisticVersions);
lb.GetLeaderboardForVersion("Trophies", previous.Version, 0, 25, page => { … });
});
// The player's own archived placement.
lb.GetMyStatisticForVersion("Trophies", previousVersion, r => { /* r.Data is int? */ });
ActiveVersion / PreviousVersion / FindValue are pure statics — no
session required, so your season-rollover logic is unit-testable.
Resetting (server-owned, by design)¶
IncrementPlayerStatisticVersion exists only on the Admin and Server APIs.
A client can never roll a version itself — otherwise any player could wipe a
leaderboard. Two supported paths:
- Scheduled — set the statistic's Reset frequency in Game Manager and let PlayFab roll it. Nothing to call; just read the new version.
- On demand — deploy a CloudScript/Azure handler that authorizes the caller
and calls
server.IncrementPlayerStatisticVersion, then name it in PlayFabHubSettings ▸ Leaderboards ▸ Version reset function key.
// With no function key configured this fails locally with that explanation,
// instead of pretending a client-side reset exists.
PlayFabHub.Leaderboards.IncrementStatisticVersion("Trophies", r => { … });
2. Group data — shared documents on a clan/guild¶
A PlayFab group is a first-class entity, so the Data API works on it exactly as it does on a player: one group owns a set of named JSON documents that every member reads and (subject to the group's entity policy) writes. That is the supported home for activity feeds, weekly goals, MOTDs and guild settings — no CloudScript needed for the read path.
var social = PlayFabHub.Social;
// Read one typed document — missing/first-run resolves to your fallback, Ok.
social.GetGroupObject<ClubGoals>(groupId, "Goals", r => Render(r.Data), new ClubGoals());
// Write one document.
social.SetGroupObject(groupId, "Goals", new ClubGoals { weeklyWins = 50 });
// Write several at once, with optimistic concurrency (the right pattern for an
// append-only activity feed: read ProfileVersion, append, write it back).
social.GetGroupData(groupId, read =>
{
var feed = EntityObjects.Read(read.Data, "Feed", new List<FeedEntry>());
feed.Add(newEntry);
social.UpdateGroupData(groupId,
new Dictionary<string, object> { { "Feed", feed } },
write => { /* !Ok when another member wrote first — re-read and retry */ },
expectedProfileVersion: read.Data.ProfileVersion);
});
social.DeleteGroupObject(groupId, "Goals");
Access control. Reads/writes are authorized by the caller's entity token
against the group's entity policy. Verify the effective group roles and entity-object policy with admin, member
and non-member accounts. If a write returns Forbidden, review that specific
permission or route the operation through an authorized server handler; do not
open writes to everyone. Protected grants belong on a trusted backend. See
feature setup.
Also new on the Groups surface: UpdateGroup (rename / repoint roles),
ChangeMemberRole (promote an officer), ListMyMembershipOpportunities
(pending invites + applications) and AcceptGroupInvitation.
3. Scheduled events — a Title Data window¶
LiveOpsManager reads one Title Data key (default LiveEvents, configurable)
and filters it against the clock. The package owns only the scheduling
contract; everything game-specific rides along in payload.
// Title Data ▸ LiveEvents — an array…
[
{ "id": "xp_weekend", "type": "boost",
"startUtc": "2026-08-01T00:00:00Z", "endUtc": "2026-08-03T23:59:59Z",
"priority": 10, "payload": { "multiplier": 2 } }
]
// …or a map keyed by id (the key fills in a missing "id").
{ "xp_weekend": { "type": "boost", "endUtc": "2026-08-03T23:59:59Z" } }
Times are UTC: ISO-8601 (with or without Z) or a Unix epoch in seconds or
milliseconds. An empty/missing bound means open ended — no startUtc is
already running, no endUtc never expires.
PlayFabHub.LiveOps.GetActiveEvents(r =>
{
foreach (var e in r.Data) // highest priority first
{
var boost = e.GetPayload<BoostPayload>();
ShowBanner(e.id, e.TimeUntilEnd(DateTime.UtcNow));
}
});
PlayFabHub.LiveOps.GetUpcomingEvents(r => { /* the "coming soon" rail */ });
PlayFabHub.LiveOps.GetTopActiveEvent(r => { /* one banner, or null */ });
- Values come from the Player Data manager's Title Data cache, so a screen
that asks every frame costs one request per
titleDataCacheSeconds. Enable theplayerDatafeature alongsideliveOps(the call fails with that message if you don't). - A missing key or malformed JSON resolves to an empty list with
Ok(one logged warning) — a bad live-config push must never take the main menu down. eventWindowGraceSecondswidens both ends of every window to absorb device clock skew.ParseEvents/ActiveAt/UpcomingAtare pure statics — unit-test your event schedule without a session.
Server authority. An event window read here decides what the UI shows. It must never be what decides a reward is owed — re-check the window inside the CloudScript handler that grants anything, or a device with a wound-forward clock claims the event early.
4. Matchmaking — ticket polling with cancel¶
CreateMatchmakingTicket + GetMatchmakingTicket are the raw calls; every
consumer then writes the same interval/timeout/cancel loop. FindMatch is that
loop.
MatchmakingPoll poll;
void StartSearch()
{
poll = PlayFabHub.Multiplayer.FindMatch(
queueName: null, // null = config default
onComplete: r =>
{
if (r.Ok) EnterMatch(r.Data); // players + team assignments
else ShowMessage(r.Error.Message); // timeout / cancel / service error
},
onStatusChanged: status => label.text = status); // "WaitingForPlayers", …
}
void OnCancelButton() => poll?.Cancel();
void OnDestroy() => poll?.Cancel();
- The poll ends with exactly one
onCompletecall — matched, timed out, cancelled, or failed — so a UI can drive its state machine from that single edge without leaking a spinner. - Polling runs on unscaled time, so it keeps ticking on a paused
(
Time.timeScale = 0) matchmaking screen. - The interval is clamped to
MultiplayerManager.MinTicketPollIntervalSeconds(6s) — PlayFab allows at most 10 ticket polls per minute per ticket. Cancel()also cancels the ticket server-side (cancelTicketOnStop), so an abandoned search stops matching other players into a lobby nobody joins.poll.Cancel()is safe before the ticket even exists: cancelling mid-create cancels the ticket the moment it comes back.- Already own the ticket? Use
PollMatchmakingTicket(ticketId, queue, …).
Without a runner (edit mode, no play mode) the ticket is read exactly once and the poll completes with a local error explaining that.
5. Handler envelopes & typed remote config¶
Two small conveniences that remove the same hand-written block from every service class.
// { "ok": true, … } / { "error": "insufficient funds" } handled once.
// Field names come from CloudScriptConfig.errorField / okField — clear them to
// opt out, or rename them to match a contract you already ship.
PlayFabHub.CloudScript.ExecuteChecked<ClaimResult>("claimDailyReward", null, r =>
{
if (!r.Ok) ShowMessage(r.Error.Message); // carries the handler's own message
});
// Typed, cached remote config. Missing key or bad JSON → your fallback, Ok.
PlayFabHub.Data.GetTitleDataObject("LevelCurve", r => Apply(r.Data), new LevelCurve());
PlayFabHub.Data.GetTitleDataValue("MotdText", r => { /* r.Data is the raw string */ });
PlayFabHub.Data.InvalidateTitleDataCache(); // after a live push / on resume
// Entity objects, typed both ways (player entity).
PlayFabHub.Data.GetObject("Loadout", r => Apply(r.Data), new Loadout());
PlayFabHub.Data.SetObject("Loadout", loadout, expectedProfileVersion: version);
PlayFabHub.Data.DeleteObject("Loadout");
HubJson (serialize / deserialize / map a loose PlayFab object graph) and
EntityObjects (read a named object out of a GetObjectsResponse) are public —
use them for your own payloads rather than re-deriving PlayFab's serializer.
Every Try* overload logs one warning and returns false instead of throwing.
HubError.IsLocal distinguishes "you asked for something impossible" (bad
argument, disabled feature, unconfigured knob — never hit the wire) from "the
service said no".