Skip to content

Saved Games Permanently Stuck After Offline Play #3379

@danangsugiartogt

Description

@danangsugiartogt

Describe the bug

After a device commits a saved game while offline and later reconnects, subsequent save attempts permanently fail to upload to server even when online. The device becomes stuck in this state indefinitely with NO recovery mechanism except triggering a conflict from another device.

Verified via Google Takeout: Server data confirmed to be stale/outdated, proving uploads are failing.

Root Cause (from reverse engineering): The SnapshotMetadata.hasChangePending() method in the underlying Google Play Services library returns stale data due to an immutable final boolean field in cached SnapshotMetadataEntity objects that never updates after successful upload.


To Reproduce

Steps to reproduce the behavior:

  1. Initialize GPGS with saved games on Device A
  2. Disable internet connection on Device A (go offline)
  3. Call PlayGamesPlatform.Instance.SavedGame.CommitUpdate() to save game while offline
  4. Re-enable internet connection on Device A (go back online)
  5. Call PlayGamesPlatform.Instance.SavedGame.CommitUpdate() to save game while online
  6. Verification Method 1: Check saved games on another device or Play Games web → New data is NOT on server
  7. Verification Method 2: Export account data via Google Takeout → Select "Play Games Services" → Confirms server has STALE/OUTDATED data
  8. Try restarting app → Issue PERSISTS (not a memory cache issue)
  9. Try using DataSource.ReadNetworkOnlySTILL FAILS (cache persists in Play Services system storage)
  10. ONLY FIX: Commit from Device B with same account → Triggers conflict → After resolving conflict, Device A can upload again

Expected behavior

After reconnecting to the internet, subsequent save operations should successfully upload to the server, regardless of previous offline commits.

If using DataSource.ReadCacheOrNetwork with pending changes, SDK should:

  1. Check if cached metadata has hasChangePending() = true
  2. If true AND device is online, validate with server first
  3. Update cache with fresh server state
  4. Then proceed with operation

Observed behavior

Save operations silently fail to upload to the server. The device is permanently stuck with no self-recovery mechanism:

  • ❌ App restart doesn't help
  • ❌ Network reconnection doesn't help
  • ❌ Waiting doesn't help
  • ❌ Even DataSource.ReadNetworkOnly doesn't help
  • Google Takeout confirms: Server data is STALE/OUTDATED
  • ONLY conflict resolution from another device fixes it

Impact:

  • Single device: Local saves work, but data never syncs to cloud
  • Multi-device scenario: Switching to Device B fetches outdated data from server → All progress on Device A after bug = PERMANENTLY LOST
  • Device loss/damage: All local-only progress lost forever (never uploaded to server)

Real-World Example:

Device A (stuck after offline play):
Day 1-3: Play online, reach Level 20 (local ✅, server still Level 5 ❌)
Google Takeout: Shows Level 5 (stale data)

Switch to Device B:
Fetches from server → Gets Level 5 (outdated!)
Progress Level 5→20 from Device A = LOST ❌

Bug Report

I can provide Android bug report if requested. However, the issue is reproducible via Google Takeout verification (server data inspection) which definitively proves upload failure.


Screenshots

N/A - Bug is related to server upload failure, verifiable via Google Takeout data export.


Versions

  • Unity version: 2022.3.62f2
  • Google Play Games Plugin for Unity version: v2.0.0
  • Dependencies from mainTemplate.gradle:
    implementation 'com.google.games:gpgs-plugin-support:2.0.0'
    implementation 'com.google.android.gms:play-services-base:18.9.0'

Additional context

Severity: CRITICAL

This bug affects ALL games using offline save functionality. Single offline session permanently breaks cloud sync for that device.

Technical Analysis (Reverse Engineering)

Methodology Note: This analysis was performed by decompiling the publicly available play-services-games:24.0.0 library using jadx for bug investigation purposes only.

Root Cause: Immutable Field in SnapshotMetadataEntity

The underlying Google Play Services library (com.google.android.gms:play-services-games) has a critical design flaw in SnapshotMetadataEntity:

// SnapshotMetadataEntity.java (decompiled from play-services-games:24.0.0)

// Line 65-66: Field declaration
@SafeParcelable.Field(id = 13, getter = "hasChangePending")
private final boolean zzl;  // ← IMMUTABLE! Cannot be changed after construction

// Line 88: Constructor sets value once
this.zzl = z;  // Set once, never updated

// Line 156-158: Getter method
@Override
public boolean hasChangePending() {
    return this.zzl;  // Always returns the initial value
}

There is NO setter method for this field after object construction.

Why It Causes the Bug

  1. Offline commit → SDK creates SnapshotMetadataEntity with zzl = true
  2. Entity is persisted in Play Services database (/data/data/com.google.android.gms/)
  3. Device reconnects → Cached entity still has zzl = true (immutable!)
  4. App calls OpenWithAutomaticConflictResolution with DataSource.ReadCacheOrNetwork:
    • SDK returns cached metadata without server validation
    • Cached metadata has hasChangePending() = true (stale!)
  5. SDK sees pending=true → assumes "upload already in progress" → blocks new commit
  6. Infinite loop - Cache never invalidates, even across app restarts
  7. Cache only refreshes when conflict resolution forces fresh server fetch

Correct Implementation Exists (SnapshotMetadataRef)

The library includes another implementation that does NOT have this bug:

// SnapshotMetadataRef.java (line 90-92)
@Override
public final boolean hasChangePending() {
    return getInteger("pending_change_count") > 0;  // Dynamically reads from database
}

This implementation correctly reflects real-time state by querying the database, which updates after successful sync.

The freeze() Mechanism

// SnapshotMetadataRef.java - Line 129-130
public final Object freeze() {
    return new SnapshotMetadataEntity(this);  // Creates immutable snapshot
}

// SnapshotMetadataEntity.java - Line 257 (Copy Constructor)
this.zzl = snapshotMetadata.hasChangePending();  // Snapshots current value

When .freeze() is called, it creates a SnapshotMetadataEntity that snapshots the current state. Once cached, this frozen state persists even if the underlying database state changes.

Timeline of the Bug

Step 1: Offline Commit
   → SDK creates SnapshotMetadataEntity with zzl=true ✅
   → Database: pending_change_count=1 ✅
   
Step 2: Background Sync (Succeeds or Fails Silently)
   → Upload to server fails (confirmed via Google Takeout) ❌
   → Cached SnapshotMetadataEntity: zzl=true ❌ (NEVER UPDATED!)
   
Step 3: App Calls ReadCacheOrNetwork
   → Returns cached SnapshotMetadataEntity
   → hasChangePending() returns zzl=true ❌ (STALE!)
   → SDK blocks new commit
   
Step 4: Infinite Loop
   → Google Takeout shows: Server has STALE data ❌
   → No recovery mechanism exists

NO Workaround Exists

There is NO reliable application-level workaround:

  • DataSource.ReadNetworkOnly doesn't help (cache persists in Play Services system storage, not app cache)
  • ❌ App restart doesn't help
  • ❌ Clear app cache/data doesn't help
  • ❌ Reinstall app doesn't help
  • ONLY triggering conflict from another device works (requires 2nd device!)

Important Notes

  • Unity GPGS Plugin v2.0.0 does NOT expose HasUncommittedChanges property
  • Developers cannot directly check upload status
  • SDK fails silently - no error callback
  • Behavior inferred from observed symptoms + reverse engineering + Google Takeout verification

Evidence

  • Decompiled source code shows private final boolean zzl (immutable, no setter)
  • Observed behavior matches theoretical bug pattern
  • Google Takeout verification: Server data confirmed stale when bug occurs
  • Google Takeout verification: Server data updates only after conflict resolution
  • SnapshotMetadataRef implementation is correct (dynamic query)
  • SnapshotMetadataEntity implementation is broken (static immutable field)

Affected Use Cases

  • ✅ Games with offline play (most mobile games)
  • ✅ Auto-save implementations
  • ✅ Multi-device players (tablet + phone) - CRITICAL IMPACT
  • ✅ Users with intermittent connectivity
  • ✅ Users who upgrade/change devices

User Impact Score

Scenario Impact Risk Level
Single device only Low Progress safe locally
Multi-device player CRITICAL Permanent data loss
Device upgrade/replacement HIGH Progress not transferred
Device damage/loss HIGH All progress lost

Related Information

This bug may be related to or affect:

  • Cloud Save API caching mechanisms
  • Snapshot conflict resolution flows
  • Other Play Services APIs with similar caching patterns

Obfuscation Note

Field names like zzl, zza, zzb are the result of ProGuard/R8 obfuscation. Original field names are unknown, but can be inferred from @SafeParcelable.Field annotations:

  • zzlhasChangePending (confirmed by getter = "hasChangePending")
  • zzagame (confirmed by getter = "getGame")
  • etc.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions