Adding seasonal content to a live service game sounds straightforward on paper: a season starts, players earn XP, unlock rewards, and when the season ends the XP counter resets and a new season begins. The cosmetics and unlocks they earned stay with them forever. The leaderboard wipes. A fresh start.
Treating XP resets and cosmetic persistence as a single operation is a mistake; they are distinct data lifecycles. When studios fail to separate them within the player record, seasonal resets often accidentally wipe earned rewards. Avoiding this costly error requires a data architecture that recognizes these as two independent lifecycles from the start.This article covers how to model the data correctly from the start, why the game server has to own every XP write, how to wire this up in Unity and Unreal Engine, and what a managed backend takes off your plate so you are not building the season pass infrastructure from scratch.
The Problem with Treating All Progression Data the Same
When studios first build progression, it usually lives in one place: a player profile record that holds Experience Points (XP), level, unlocks, currency, and stats. That works fine for a game without seasonal content.
Add seasons and that single record creates a conflict. The season resets, the record gets cleared or updated, and the reset logic cannot distinguish between data that should go and data that should stay. The season XP total goes to zero as expected. Cosmetics go with it because they were in the same record.
The fix is not a smarter reset function. The fix is a different data architecture from the start: two separate records with two separate lifecycles.

What Goes in Each Bucket
The first decision before writing any code is drawing the line between what survives a season rollover and what does not. Get this wrong and you will be refactoring around it every time a season ends.
Resets at season end:
-
Season pass Experience Points (XP) accumulated this season.
-
Current season tier.
-
Pass type (free or premium) for the current season.
-
Claimed tier reward IDs for the current season.
- Season-specific challenge progress.
-
Seasonal leaderboard score.
Never resets:
-
Claimed cosmetics, skins, emotes, and unlocks.
-
Virtual currency balances (unless your design explicitly wipes them).
-
Lifetime statistics: total matches played, total kills, career wins, cumulative playtime.
-
Claimed tier reward IDs for the current season.
- Account-level achievements.
-
Entitlements from direct purchases.
The useful test for any piece of data: would a player be upset if it was gone after season rollover? Cosmetics they grinded for, yes. Season XP, no. Currency lands somewhere in between, and that call belongs in your game design document, not in the reset logic. The important thing is that whatever the answer is, the data architecture enforces it consistently rather than depending on a conditional.
Designing the Data Model
With the two buckets defined, the implementation follows a clear pattern: give every piece of ephemeral data a season identifier so the backend knows which season it belongs to. When a new season starts, the system creates a fresh season-scoped record for each player, keyed to the new season ID. The previous season record gets archived, not deleted. The permanent account record is not touched.
A season-scoped record looks like this:
{
"playerId": "abc123",
"seasonId": "s2026_q2",
"seasonXP": 3500,
"currentTier": 14,
"passType": "premium",
"claimedTierRewards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
"challengeProgress": {
"weekly_win_5_matches": 3,
"play_10_matches": 10
}
}
The account record held separately:
{
"playerId": "abc123",
"lifetimeMatches": 847,
"lifetimeKills": 12094,
"currencyBalance": 2400,
"ownedCosmetics": ["skin_glacier", "emote_wave", "banner_elite_s3"]
}
When a player claims the tier 14 reward (a skin), that entitlement moves to the account record’s ownedCosmetics list. The season record marks tier 14 as claimed. When season three starts, a new season record initializes with seasonId: “s2026_q3” and zeroed values. The account record’s cosmetics list is unchanged.
One specific trap with lifetime statistics: if you track kills as a single value and reset it seasonally, you have destroyed the lifetime total. Track seasonKills in the season record and lifetimeKills in the account record and increment both independently on each kill event. These are not two names for the same counter. They serve different purposes and have different lifecycles.
Server Authority Is Not Optional
Local storage (Unity's PlayerPrefs, Unreal's UGameInstance, local save files) is fine as a client-side display cache. It cannot be the source of truth for XP or tier data.
If the data is stored on the client, players will find a way to manipulate the data. You want your backend to be the source of authority. Ensuring all players have an enjoyable experience and not have to contend with cheating. The flow that prevents tampering:

Implementing Player Progression in Unity Projects
Unity Gaming Services (UGS) provides Cloud Save and a basic Statistics service. What it does not provide is a native season pass service with tier management, XP threshold logic, or reward grant sequencing. Studios building on UGS handle the season pass layer themselves, typically on a custom game server or through a managed backend.
Server-side XP grant:
// Called from your game server after validating match outcome
// PlayerId and xpAmount come from server-side match data, not the client
public async Task<SeasonProgressResult> GrantSeasonXP(string playerId, int xpAmount)
{
string currentSeasonId = await GetActiveSeasonId();
// Update the season-scoped record
var seasonRecord = await CloudSaveService.LoadAsync(
new HashSet<string> { $"season_{currentSeasonId}" });
var progressData = JsonUtility.FromJson<SeasonProgressData>(
seasonRecord[$"season_{currentSeasonId}"].Value.GetAsString());
progressData.seasonXP += xpAmount;
// Check tier thresholds and award rewards
while (progressData.seasonXP >= GetTierXPThreshold(progressData.currentTier + 1))
{
progressData.currentTier++;
await GrantTierReward(playerId, currentSeasonId, progressData.currentTier);
}
// Write back to server-protected record
await CloudSaveService.ForceSaveAsync(new Dictionary<string, object>
{
{ $"season_{currentSeasonId}", progressData }
});
return new SeasonProgressResult { UpdatedProgress = progressData };
}
Client-side season state read:
// Runs on login and after server confirms XP grant
public async Task RefreshSeasonProgress()
{
string currentSeasonId = await GetActiveSeasonId();
var data = await CloudSaveService.LoadAsync(
new HashSet<string> { $"season_{currentSeasonId}" });
var progress = JsonUtility.FromJson<SeasonProgressData>(
data[$"season_{currentSeasonId}"].Value.GetAsString());
// Update UI from authoritative data
seasonXPBar.value = (float)progress.seasonXP / GetTierXPThreshold(progress.currentTier + 1);
currentTierText.text = $"Tier {progress.currentTier}";
}
Permanent unlocks stay in a separate record, server-write-protected:
// Read-only from the client -- server grants entitlements on tier claim
public async Task LoadPermanentUnlocks()
{
var data = await CloudSaveService.LoadAsync(
new HashSet<string> { "ownedCosmetics", "lifetimeStats" });
ApplyUnlocksToCharacter(data);
}
The gap to be aware of: everything in the sample above (the tier threshold logic, the reward grant function, the season lifecycle management, the admin tooling that lets a designer publish a new season without a code deploy) is custom work your team builds and maintains. UGS gives you the storage layer. The season pass behavior on top of it is yours.
Implementing Player Progression in Unreal Engine Projects
In Unreal, UGameInstance persists through level transitions within a session. It is useful as a local cache for what the player currently sees on screen. For seasonal progression data, keep it synchronized from the backend rather than treating it as authoritative: the backend is the source of truth, UGameInstance is the display state.
Custom season subsystem:
// USeasonProgressionSubsystem : public UGameInstanceSubsystem
void USeasonProgressionSubsystem::RefreshFromBackend(const FString& PlayerId)
{
// Authoritative read from the backend
BackendClient->GetPlayerSeasonProgress(
PlayerId,
CurrentSeasonId,
FOnSeasonProgressReceived::CreateUObject(this,
&USeasonProgressionSubsystem::OnProgressDataReceived));
}
void USeasonProgressionSubsystem::OnProgressDataReceived(
const FSeasonProgressData& ProgressData)
{
// Cache locally for UI -- display cache only, not ground truth
CachedSeasonProgress = ProgressData;
OnProgressRefreshed.Broadcast(ProgressData);
}
void USeasonProgressionSubsystem::ReportMatchCompletion(
const FString& PlayerId,
const FMatchResult& Result)
{
// Send outcome to game server -- it validates and calls the backend to grant XP
// Client does not calculate or transmit XP amounts
GameServerProxy->SubmitMatchResult(PlayerId, Result,
FOnMatchResultAcknowledged::CreateUObject(this,
&USeasonProgressionSubsystem::OnMatchResultProcessed));
}
void USeasonProgressionSubsystem::OnMatchResultProcessed()
{
RefreshFromBackend(LocalPlayerId);
}
Specific Unreal considerations include Epic Online Services (EOS), which manages entitlements and stats but lacks native season pass tier management. You should keep EOS for identity and social features while adding a separate backend for the season pass layer via the Online Subsystem.
Additionally, differentiate between permanent character levels and ephemeral season tiers by storing them in separate records. Storing progression in local USaveGame objects is risky as they lack server authority and prevent safe reset logic. Never store match eligibility, tier state, or rewards in local saves; reserve them only for cosmetic preferences.
How a Managed Backend like AccelByte Differs from Unity and Unreal
The Unity and Unreal examples highlight a common challenge: while storage primitives are available, the actual season pass logic is absent. Developers are left to build and maintain complex systems from scratch, including tier threshold APIs, validated XP grant endpoints, and reward sequencing. Furthermore, you must engineer a season lifecycle state machine—handling states from draft to archive—and develop admin tools so designers can update seasons without needing a code deployment.
AccelByte Gaming Services (AGS) is a modular backend platform built for online games, providing ready-to-use services covering identity, progression, matchmaking, economy, and more. Studios that do not want to build and maintain the season pass layer themselves can use AGS's Season Pass service, which is part of the Online module.
The Season Pass service in AGS handles the full lifecycle:
→ Introduction to AGS Season Pass on AccelByte documentation portal
The server-side XP grant replaces the custom endpoint from the Unity section above:
// Unity SDK -- server-side XP grant
ServerSeasonPass serverSeasonPass = AccelByteSDK
.GetServerRegistry().GetApi().GetSeasonPass();
serverSeasonPass.GrantExpToUser(
userId,
exp,
source: SeasonPassSource.PAID_FOR,
(Result<UserSeasonInfoWithoutReward> result) =>
{
if (result.IsError)
{
Debug.LogError($"XP grant failed: {result.Error.Message}");
return;
}
// result.Value has updated tier and XP totals
// Tier advancement and reward grants are handled by the service
UpdateClientSeasonUI(result.Value);
});
FuzzyBot: When Data Persistence Is the Feature
FuzzyBot, the studio behind Lynked: Banner of the Spark, built a game where players move between online town servers and offline mission sessions. Player progress and town states need to survive those transitions correctly, every time, whether the player is online or offline.
With a team of five and no dedicated backend engineering capacity, FuzzyBot used AccelByte's Cloud Save to handle offline-to-online persistence. The service's concurrency guards prevented data corruption when multiple clients attempted to write to the same record simultaneously.FuzzyBot cut six months off their development timeline and launched Lynked on schedule without adding backend engineers.

Their Cloud Save concurrency pattern is worth noting in the context of seasonal resets: when a season end event fires across many active sessions simultaneously, the backend needs to handle concurrent writes to player records without producing inconsistent state. Server-authoritative writes with conflict resolution handle this. Client-managed saves do not.
Get Started with AccelByte for Free
If you are building your first season system or untangling a messy one, the architecture decisions above are the foundation.
AccelByte Gaming Services (AGS) Shared Cloud covers the full season pass lifecycle: tier management, XP grants, reward sequencing, and season configuration, all without a code deploy. AGS Shared Cloud is free during development, with no time limit.
Once your game is live, billing is on a per-CCU basis, so you pay for what players actually use. If you are already live and want to validate the workflow before committing, there is a 90-day free trial available.
Get Started for Free → or Talk to Us →
Featured Customer Stories
How Striking Distance Studios Cut Crash Resolution Time from 2hrs to 20mins with AI & ADT MCP Server
From Open Beta to Launch: Skybound’s Invincible VS Goes Live, Running on AccelByte
How UNSEEN Iterates As Quickly As If They Worked Side-by-Side in an Office
Featured Blog Posts
How to Set Up and Run Dedicated Game Servers Across Multiple Regions Without a DevOps Team
Unity Gaming Services vs AccelByte Gaming Services for Mobile Game Development Studios
How to Catch Crashes During QA Before They Reach Players
Find a Backend Solution for Your Game!
Reach out to the AccelByte team to learn more.