July 23, 2013
Peter Heinrich
Save game data in the cloud to protect your customers’ progress and support play from multiple devices
In just a few short years, the explosion in popularity of smart phones and tablets has transformed gaming, as touch controls, geolocation, and micropayments enter the mainstream. Online storage is also becoming more common, as developers adjust to a major challenge of mobile devices: physical durability. Keeping your data in the cloud starts to look mighty attractive compared to keeping it on a device that’s easily misplaced, damaged, or stolen.
Blob storage is a common way to save data online (game data is simply written to the cloud as one big chunk). If two devices update the same data, though, one of the updates must be ignored or overwrite the other. You have to decide which update to keep (based on a timestamp or version number, perhaps), or maybe prompt the user to choose between them. Either way, data is lost.
Because of the overhead involved, many developers don’t even bother with this naïve approach. This is a huge disservice to the player, whose game progress is completely lost if anything happens to his or her mobile device. Whispersync for Games saves game data for you, and works across all Android devices (including Kindle Fire).
Mergeable and Non-mergeable Data
Whispersync was designed to prevent data loss, make developer or player intervention unnecessary when resolving conflicts, and be dead-simple to integrate and use. It doesn’t distinguish between online and local storage, so the programmer doesn’t have to handle separate cases for saving to disk and cloud. It does this by manipulating only mergeable data.
Game data is mergeable if a simple rule can be defined to resolve conflicting values. For example, the rule associated with the best completion time might be, “Take whichever value is smallest.” To keep track of a player’s best score, we might use, “Take whichever value is highest.” In some cases, the most recent value may be the most important, so the appropriate rule would be, “Take whichever value is newest.” These rules apply at a granular level; Whispersync doesn’t treat game data as one big chunk.
Not all game data can be resolved using simple rules, though, in which case we call it non-mergeable. The current state of a chess board, for example, requires a complex tree structure to describe it. Reconciling two versions of the board is more complicated than just choosing the “lowest” or “highest” one.
Describing Game Data Using Syncable Types
Fortunately, a lot of game data is naturally mergeable or can be adjusted to be so. Whispersync offers many different syncable types with several built-in rules to resolve conflicts. They can be used to model simple and complex game data.
A global GameDataMap object represents the root storage for your game, storing named values of the types above. Setting a value that doesn’t exist will create it on the fly. The accessor used to retrieve a value determines the conflict resolution strategy that will be associated with it.
public static final String SKILLS = "skills";
public static final String TOTAL_TIME = "totalTime";
public static final String LEVEL_MAP = "levelMap";
public static final int NUM_LEVELS = 5;
public static final int MAX_ITEMS = 3;
public static final String SCORE = "score";
public static final String STARS = "stars";
public static final String BEST_SCORES = "bestScores";
public static final String BEST_TIMES = "bestTimes";
GameDataMap gameDataMap;
public void initGameData() {
gameDataMap = AmazonGamesClient.getWhispersyncClient().getGameData();
// These will be independent of level.
gameDataMap.getStringSet(SKILLS);
gameDataMap.getAccumulatingNumber(TOTAL_TIME);
// Use nested maps to establish some per-level values.
for (int i=0; i < NUM_LEVELS; i++) {
GameDataMap levelMap = gameDataMap.getMap(LEVEL_MAP + i);
// Each level will have its own copy of these values.
levelMap.getLatestNumber(SCORE);
levelMap.getHighestNumber(STARS);
levelMap.getHighNumberList(BEST_SCORES).setMaxSize(MAX_ITEMS);
levelMap.getLowNumberList(BEST_TIMES).setMaxSize(MAX_ITEMS);
}
}
In the example above, we use initGameData() to establish the structure of the data we’ll synchronize, even though we don’t actually set any values. (It can be helpful to define the data layout in one place like this, even if it’s not strictly required.) The code effectively says,
Updating and Retrieving Game Data
Whispersync abstracts all game data persistence, so we can update or retrieve values using simple getters and setters. We don’t have to worry about managing a network connection, saving to disk or the cloud, or reconciling local and online versions of variables we have in memory. When we add to the running total of time played, for example, Whispersync automatically saves the delta to disk and updates the cloud if connected. If the player’s device is currently offline, the update will be queued and delivered later.
public void updateTotalTime(int timePlayed) {
SyncableAccumulatingNumber totalTime = gameDataMap.getAccumulatingNumber(TOTAL_TIME);
// Add the time played to the running total.
totalTime.increment(timePlayed);
// Output the new total to the console.
System.out.println("Total Time Played = " + totalTime.asInt());
}
Likewise, we can record skills as the player masters them and easily iterate over the set to display the ones learned so far:
public void learnSkill(String skill) {
SyncableStringSet skillSet = gameDataMap.getStringSet(SKILLS);
// Add the new skill to the player's repertoire. Note that this list
// can only expand; strings cannot be removed.
skillSet.add(skill);
// Output all skills to the console.
for (SyncableStringElement s : skillSet.getValues()) {
System.out.println("Skill = " + s.getValue());
}
}
Updating numbers and number lists is just as straightforward, so persisting all of the player’s progress at the completion of a level can be done in just a few lines:
public void finishLevel(int level, int score, int stars, int time) {
GameDataMap levelMap = gameDataMap.getMap(LEVEL_MAP + level);
// Save the score for this level. Newer values will always overwrite
// previous scores.
levelMap.getLatestNumber(SCORE).set(score);
// Try to set the new maximum stars attained on this level. If the
// value is less than the current maximum, this call does nothing.
levelMap.getHighestNumber(STARS).set(stars);
// Try to add this score to the list of all-time bests. If it's not
// high enough, this update will be ignored.
levelMap.getHighNumberList(BEST_SCORES).add(score);
// Try to add this completion time to the list of all-time bests. If
// it's not low enough, this update will be ignored.
levelMap.getLowNumberList(BEST_TIMES).add(time);
updateTotalTime(time);
}
The accessors on GameDataMap and Whispersync’s syncable types make it easy to manipulate game data and define how conflicts should be resolved.
Think Mergeable!
Whispersync for Games automatically resolves conflicts for data it can merge, which makes it worthwhile to describe game data using syncable types when possible. Since Whispersync supports high, low, and most recent numbers and number lists; running totals; latest strings and string lists; sets of strings; and nested maps supporting hierarchical structures, a wide range of game data can be modeled.
Thinking about your game data in mergeable terms lets you push a lot of overhead out of your code. Let Whispersync handle the heavy lifting. For more information on integrating Whispersync into your game, see the online documentation.