Cloud Save - Code

Save player data in the cloud [PROF-CloudSave-03]

This guide includes everything needed to use the Cloud Save feature in the "Beamable SDK for Unity".

The purpose of this feature is to allow the player to store and retrieve progress in their game.

The Cloud Data is fetched online and stored locally; scoped by game and player. As changes are detected, the system automatically keeps data in sync.

API

Unlike many Beamable Features, Cloud Save does not require the usage of a specific Beamable Feature Prefab. The main entry point to this feature is C# programming.

šŸ“˜

Learning Fundamentals

Game makers who are new to Unity and C# can review the fundamentals here.

ā€¢ See Beamable: Asynchronous Programming for more info

Here are API highlights for CloudSavingService.

Note: This API is demonstrated in the CloudSavingServiceExample.cs below.

Method NameDetail
InitResolves local game data against what the server has stored (downloads server content and uploads local content).

Note: This accepts an optional pollingIntervalSecs parameter which determines is how often the service will scan for local file changes
isInitializingReturns a bool. The value is true during service startup, during syncing. This value is false after the client/server data matches properly.

Note: It is suggested to check if (!isInitializing) before calling Init or ReinitializeUserData to prevent unneeded concurrent operations
EnsureRemoteManifestChecks if player data exists on the Beamable back-end and returns the manifest if it exists.

Note: This is recommended for advanced users only.
ReinitializeUserDataForces a download of player data from the Beamable back-end. This replaces all local content with what is stored by the back-end.
UpdateReceivedThe system invokes this event when content has changed on the server. This may be triggered by the Portal or by another game client.
OnErrorThe system invokes this event when there is an error downloading, saving, or uploading Cloud Data.

Cloud Data Manifest

When changes are done uploading to the server, a manifest will be written locally here:

Path.Combine(Directory.GetParent(_beamContext.Api.CloudSavingService.LocalCloudDataFullPath).ToString(),"cloudDataManifest.json");

Cloud Saved files are written to an object store that is managed by the Cloud Saving service. The key of the object is the MD5 checksum of the content in the file, this is done to preserve history of the files.

The manifest tracks the MD5 checksum(etag) of the file and the common name(key) of the file, so we can fetch the object by its MD5 checksum and write it to disk using the common name.

Json Format

{
   "id":"1366764038702081",
   "manifest":[
      {
         "bucketName":"beam-cloud-saving",
         "key":"ComplexFile.json",
         "size":642,
         "lastModified":20210602112148,
         "eTag":"B0FAB230EE1DB774FFE1DE866309D720"
      }
   ],
   "replacement":false
}

Syncing Of Data

The downloading operation uses Unity's DownloadHandlerFile and content writes to a /tmp/ folder first. The file checksum is checked against the manifest checksum, and upon successful validation the tmp file(s) are deleted properly.

Note If the destination files are kept open by some unrelated system during the syncing process, the CloudSavingService will reattempt several times. Upon any ultimate failure, an IOException will be thrown.*

Storage Of Data

Data is stored in within Unity's Application.persistentDataPath and prefixed with the Customer ID (CID), Project ID (PID), and Player ID (DBID). This is to ensure data is scoped to a game and a player. The full path is available at CloudSavingService.LocalCloudDataFullPath.

Once the Cloud Saving service is initialized, the local Cloud Saving storage path is monitored by the service automatically for changes. When changes are detected, references to the changed files will be queued up and uploaded in batches.

<persistentDataPath>/beamable/cloudsaving/<cid>/<pid>/<dbid>/<objectkey>

Error Handling

The CloudSavingService.Init() is expected to thrown an error if the cloud save does not exist for the user yet. In normal operations, this error is expected to occur once; the very first time. It is recommended to handle the error via CloudSavingService.OnError.

See the SetupBeamable within the CloudSavingServiceExample.cs example below for more information.

Examples

Here are examples which cover common programming needs.

šŸ“˜

Beamable SDK Examples

ā€¢ The following example code is available for download at GitHub.com/Beamable_SDK_Examples

In this CloudSavingServiceExample.cs, the AudioSettings for a player is loaded if it exists. Otherwise it is created and saved. This is a common pattern for handling data which may or may not exist yet.

using System;
using System.Collections.Generic;
using System.IO;
using Beamable.Api.CloudSaving;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.SceneManagement;

namespace Beamable.Examples.Services.CloudSavingService
{
    [Serializable]
    public class RefreshedUnityEvent : UnityEvent<CloudSavingServiceExampleData> {}

    /// <summary>
    /// Holds data for use in the <see cref="CloudSavingServiceExample" />.
    /// </summary>
    [Serializable]
    public class CloudSavingServiceExampleData
    {
        public bool IsFirstFrame = true;
        public bool IsDataFoundFirstFrame = false;
        public MyCustomData MyCustomDataCloud = null;
        public MyCustomData MyCustomDataLocal = null;
        public List<string> InstructionLogs = new List<string>();
        public DataState DataState = DataState.Initializing;
    }

    public enum DataState
    {
        Initializing,
        Pending,
        Synced,
        Unsynced
    }

    /// <summary>
    /// Represents the Cloud Saving data stored on server.
    /// </summary>
    [Serializable]
    public class MyCustomData
    {
        public float Volume = 100;
        public bool IsMuted;

        public override string ToString()
        {
            return $"[MyCustomData (" +
                   $"Volume = {Volume}, " +
                   $"IsMuted = {IsMuted})]";
        }
    }

    /// <summary>
    /// Demonstrates <see cref="CloudSavingService" />.
    /// </summary>
    public class CloudSavingServiceExample : MonoBehaviour
    {
        //  Events  ---------------------------------------
        [HideInInspector] public RefreshedUnityEvent OnRefreshed = new RefreshedUnityEvent();

        // Properties   ----------------------------------
        
        /// <summary>
        /// Dynamically build the local storage for the Cloud Saving Data object
        /// </summary>
        private string FilePath
        {
            get
            {
                // Suggested format
                string fileName = "myCustomData.json";
                
                // Required format
                return $"{_cloudSavingService.LocalCloudDataFullPath}{Path.DirectorySeparatorChar}{fileName}";
            }
        } 
        
        //  Fields  ---------------------------------------
        private BeamContext _beamContext;
        private Api.CloudSaving.CloudSavingService _cloudSavingService;
        private readonly CloudSavingServiceExampleData _cloudSavingServiceExampleData =
            new CloudSavingServiceExampleData();

        
        //  Unity Methods  --------------------------------
        protected void Start()
        {
            Debug.Log($"Start() Instructions...\n\n" + 
                      " * Run Unity Scene\n" + 
                      " * Use in-game menu\n" +
                      " * See in-game text results\n" + 
                      " * CancelMatchmaking Scene and repeat steps to test persistence\n");
            
            _cloudSavingServiceExampleData.InstructionLogs.Clear();
            _cloudSavingServiceExampleData.InstructionLogs.Add("Run Unity Scene");
            _cloudSavingServiceExampleData.InstructionLogs.Add("Use in-game menu");
            _cloudSavingServiceExampleData.InstructionLogs.Add("See in-game text results");
            _cloudSavingServiceExampleData.InstructionLogs.Add("CancelMatchmaking Scene and repeat steps to test persistence");

            SetupBeamable();
        }


        //  Methods  --------------------------------------
        private async void SetupBeamable()
        {
            _beamContext = BeamContext.Default;
            await _beamContext.OnReady;

            Debug.Log($"_beamContext.PlayerId = {_beamContext.PlayerId}");

            _cloudSavingService = _beamContext.Api.CloudSavingService;

            // Subscribe to the UpdatedReceived event to handle
            // when data on disk does not yet exist and is pulled
            // from the server
            _cloudSavingService.UpdateReceived +=
                CloudSavingService_OnUpdateReceived;

            // Subscribe to the OnError event to handle
            // when the service fails
            _cloudSavingService.OnError += CloudSavingService_OnError;

            // Check isInitializing, as best practice
            if (!_cloudSavingService.isInitializing)
                // Init the service, which will first download content
                // that the server may have, that the client does not.
                // The client will then upload any content that it has,
                // that the server is missing
                await _cloudSavingService.Init();
            else
                throw new Exception("Cannot call Init() when " +
                                    $"isInitializing = {_cloudSavingService.isInitializing}");

            // Check isInitializing, as best practice
            if (!_cloudSavingService.isInitializing)
            {
                // Resets the local cloud data to match the server cloud data
                // IF DESIRED, UNCOMMENT THE FOLLOWING LINE
                //await _cloudSavingService.ReinitializeUserData();
            }
            else
            {
                throw new Exception("Cannot call Init() when " +
                                    $"isInitializing = {_cloudSavingService.isInitializing}");
            }

            LoadAndSave();
            Refresh();
        }


        private void LoadAndSave()
        {
            _cloudSavingServiceExampleData.DataState = DataState.Pending;
            _cloudSavingServiceExampleData.MyCustomDataLocal = LoadData();

            // Determines if the data was found on
            // the very first checking of the scene
            // Useful for demonstration purposes only
            if (_cloudSavingServiceExampleData.IsFirstFrame == true)
            {
                _cloudSavingServiceExampleData.IsFirstFrame = false;
                _cloudSavingServiceExampleData.IsDataFoundFirstFrame = 
                    _cloudSavingServiceExampleData.MyCustomDataLocal != null;
            }
            
            if (_cloudSavingServiceExampleData.MyCustomDataLocal == null)
            {
                // Create Data - Default
                _cloudSavingServiceExampleData.MyCustomDataLocal = new MyCustomData
                {
                    IsMuted = false,
                    Volume = 1
                };

                SaveDataInternal(_cloudSavingServiceExampleData.MyCustomDataLocal);
            }

        }

        public MyCustomData LoadData()
        {
            _cloudSavingServiceExampleData.DataState = DataState.Pending;
            var loaded = LoadDataInternal();

            _cloudSavingServiceExampleData.MyCustomDataCloud = loaded;
            _cloudSavingServiceExampleData.MyCustomDataLocal = loaded;
            
            Refresh();
            
            return _cloudSavingServiceExampleData.MyCustomDataCloud;
        }
        
        private MyCustomData LoadDataInternal()
        {
            if (!Directory.Exists(_cloudSavingService.LocalCloudDataFullPath))
            {
                Directory.CreateDirectory(_cloudSavingService.LocalCloudDataFullPath);
            }
            
            MyCustomData myCustomData = null;
            
            if (File.Exists(FilePath))
            {
                var json = File.ReadAllText(FilePath);
                myCustomData = JsonUtility.FromJson<MyCustomData>(json);
            }
            
            return myCustomData;
        }
      
        public void RandomizeData()
        {
            _cloudSavingServiceExampleData.DataState = DataState.Pending;

            // Create Data - Random
            _cloudSavingServiceExampleData.MyCustomDataLocal = new MyCustomData
            {
                IsMuted = UnityEngine.Random.value > 0.5f,
                Volume = UnityEngine.Random.Range(0, 10) * 0.1f
            };

            Refresh();
        }

        public void SaveData()
        {
            _cloudSavingServiceExampleData.DataState = DataState.Pending;

            SaveDataInternal(_cloudSavingServiceExampleData.MyCustomDataLocal);
            
            Refresh();
        }

        private void SaveDataInternal(MyCustomData myCustomData)
        {
            var json = JsonUtility.ToJson(myCustomData);
            
            if (!Directory.Exists(FilePath))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(FilePath));
            }

            // Once the data is written to disk, the service will
            // automatically upload the contents to the cloud
            File.WriteAllText(FilePath, json);

            _cloudSavingServiceExampleData.MyCustomDataCloud = myCustomData;

        }

        public void Refresh()
        {
            // Use DataState to display info to the user via UI
            if (_cloudSavingServiceExampleData.MyCustomDataCloud == null &&
                _cloudSavingServiceExampleData.MyCustomDataLocal == null)
            {
                // Connecting
                _cloudSavingServiceExampleData.DataState = DataState.Initializing;
            }
            else if (_cloudSavingServiceExampleData.MyCustomDataCloud == null ||
                _cloudSavingServiceExampleData.MyCustomDataLocal == null)
            {
                // Data transfer pending
                _cloudSavingServiceExampleData.DataState = DataState.Pending;
            }
            else if (_cloudSavingServiceExampleData.MyCustomDataCloud ==
                _cloudSavingServiceExampleData.MyCustomDataLocal)
            {
                // Local and Cloud are Synced
                _cloudSavingServiceExampleData.DataState = DataState.Synced;
            }
            else
            {
                // Local and Cloud are Not Synced
                _cloudSavingServiceExampleData.DataState = DataState.Unsynced;
            }
            
            //Debug.Log("Refresh()");

            OnRefreshed.Invoke(_cloudSavingServiceExampleData);
        }


        //  Event Handlers  -------------------------------
        private void CloudSavingService_OnUpdateReceived(ManifestResponse manifest)
        {
            Debug.Log("CloudSavingService_OnUpdateReceived()");

            // If the settings are changed by the server...
            // Reload the scene or something project-specific to reload your game
            SceneManager.LoadScene(0);
        }


        private void CloudSavingService_OnError(CloudSavingError cloudSavingError)
        {
            Debug.Log($"CloudSavingService_OnError() Message = {cloudSavingError.Message}");
        }
    }
}