Quick answer: Record every progression change as a delta (with UUID and timestamp), queue them while offline, send the queue on reconnect, and let the server reject invalid deltas and return the authoritative state. Validate every delta server-side to block offline cheating.
A player plays your mobile game on the subway with no signal. They level up, earn coins, complete achievements. When they come back above ground, their phone reconnects and all that progress needs to arrive on the server without conflicts, duplicates, or exploits. This is an old problem with a well-understood solution: record deltas, not absolute values.
Why Not Just Sync Absolute Values?
The obvious approach is to periodically send “here is my current XP, gold, level” to the server and let it overwrite its copy. This is simple and wrong. Three things break it:
Multi-device conflicts. If the player has the game on their phone AND their tablet, both might have different absolute values. Which one wins? Whichever synced last — which may not be the newest.
Lost updates. If two devices both sync during overlapping sessions, one of them overwrites the other’s changes.
Exploit amplification. A cheater who edits save memory to set XP = 9999999 sends that absolute value on next sync and the server accepts it.
Deltas solve all three problems.
What a Delta Looks Like
{
"id": "3a7b1c9e-...", // client-generated UUID
"player_id": "alice",
"timestamp": 1728578412000, // ms since epoch
"type": "xp_gained",
"amount": 150,
"source": {
"kind": "quest_completed",
"id": "quest_forest_01",
"session_id": "...",
"client_version": "1.2.0"
}
}
Each delta is a single atomic change. A session that earns 150 XP from a quest and 30 XP from a kill records two deltas, not one “XP = 2180”. The player’s current XP is always the sum of all applied deltas, never a standalone value.
The Offline Queue
When the game is offline, new deltas go into a local queue persisted to disk. Every change to the player’s progression is both applied locally (for immediate feedback) and queued for the next sync.
void GainXP(int amount, string source)
{
var delta = new Delta {
id = Guid.NewGuid().ToString(),
type = "xp_gained",
amount = amount,
source = source,
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
// Apply locally for immediate feedback
_localState.xp += amount;
// Queue for next sync
_deltaQueue.Enqueue(delta);
PersistQueue();
}
The local state is optimistic — it shows the new XP immediately. The delta is persisted to disk so it survives a game crash.
Sync on Reconnect
When the game detects a network connection, it sends the queue to the server.
async Task SyncProgression()
{
var batch = _deltaQueue.ToArray();
if (batch.Length == 0) return;
var response = await api.PostAsync("/sync/progression", batch);
if (response.ok)
{
// Replace local state with the server's authoritative result
_localState = response.state;
// Clear the queue - these deltas are applied
_deltaQueue.Clear();
PersistQueue();
}
}
The server is authoritative. After sync, discard the optimistic local values and use what the server returned. This resolves all conflicts in one step.
Server-Side Validation
The server cannot trust the client. Validate every delta:
func applyDelta(player *Player, d Delta) error {
// Reject duplicates by UUID
if player.hasAppliedDelta(d.ID) {
return nil // already processed, idempotent
}
// Reject impossible timestamps
if d.Timestamp > time.Now().UnixMilli() + 60000 {
return errors.New("timestamp in the future")
}
// Rate-limit XP gains
recent := player.xpGainedSince(time.Now().Add(-time.Hour))
if recent + d.Amount > MAX_XP_PER_HOUR {
return errors.New("xp rate exceeded")
}
// Validate the source exists
if d.Type == "xp_gained" && d.Source.Kind == "quest_completed" {
if !questExists(d.Source.ID) {
return errors.New("unknown quest")
}
}
// Apply
player.applyDelta(d)
return nil
}
The UUID check is critical: it makes sync idempotent. If the client sends a delta, the response is lost, and the client retries, the server will see the duplicate UUID and skip it instead of applying twice.
Handling Clock Drift
Players have wrong system clocks. Never trust a client-provided timestamp as authoritative. Use it only as a hint for ordering within a batch, and reject anything too far in the future or past. Record the server’s receipt time as the canonical timestamp.
The “Conflict” Case
If the player plays on device A offline, syncs, then plays on device B offline and syncs, the server sees the deltas from both. Because deltas are commutative and idempotent, they just apply in order. The player ends up with the sum of both sessions. No user-visible conflict dialog is needed.
The only case that needs a dialog is when a local change cannot apply — for example, selling an item that was also used in a quest on another device. For that, the server returns an error code and the client shows a resolution UI.
Testing the Sync Path
Build test scenarios that simulate:
- Offline for 5 minutes, come back, sync.
- Offline for 24 hours, come back, sync.
- Two devices playing offline simultaneously, both sync.
- Partial sync failure (the first batch is accepted, the retry adds more).
- Queue larger than a single HTTP request allows (batch the batch).
Every one of these is a real case. Code against them in unit tests so regressions are caught before players hit them.
“Deltas make sync boring in the best way. Duplicate send? Idempotent. Out of order? Commutative. Missing upload? Next sync catches it. Absolute values have none of these properties.”
Related Resources
For cloud save conflict resolution specifically, see how to test cloud save conflict resolution. For broader save integrity, see how to monitor save data integrity across cloud sync. For anti-cheat on offline progression, see how to detect modded clients in multiplayer games.
Client-generated UUIDs on every delta make the entire sync pipeline idempotent. That one decision saves you from most of the pain other sync schemes suffer.