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.
We just released a major update to Amazon GameCircle, our free game service providing achievements, leaderboards, and Whispersync for Games. Games on all Android devices (including Kindle Fire, of course) can now integrate GameCircle, which is easy because we support Java, JNI, and Unity3D. Whether you submit your game to Amazon or Google, customers will benefit from the GameCircle experience. In addition, Whispersync has been dramatically improved, becoming the first and only cloud-save service to:
1) Automatically resolve data conflicts between mobile devices and the cloud,
2) Queue updates to support offline operation, and
3) Offer a simple interface that can be integrated in minutes.
The result: Your customers’ game data will automatically sync across devices—even if they play your game while temporarily offline—and you can concentrate on using the data instead of persisting it. You’re not locked in, though; you retain ownership and can always get a copy of the data you store.
We also give you flexibility when it comes to GameCircle display options, with no intrusive splash screen and configurable notification toasts. You’re in control and choose where they appear.
I’ll provide more technical details about these enhancements in future posts, but for now, let’s take a closer look Whispersync and explore its powerful new features and simplified interface.
Whispersync for Games
Whispersync does the heavy lifting when it comes to synchronizing local data to and from the cloud. It handles the tricky scenarios that you would normally have to deal with yourself, like conflicting updates from multiple devices or sync requests from a device that’s temporarily offline. You can also stop worrying about scalability; behind the curtain, Whispersync (and all of GameCircle) is powered by Amazon Web Services like S3 and DynamoDB.
Using Whispersync is easy through an API that has been completely redesigned, providing an interface similar to SharedPreferences. Saving game data to the cloud and synchronizing it between devices is as easy as retrieving a variable from a map and using (or changing) its value:
// Get the global game data map for the current player.
GameDataMap gameDataMap = AmazonGamesClient.getWhispersyncClient().getGameData();
// Look up the object representing the player’s skill rating.
// If none exists, one will be created.
SyncableNumber skillRating = gameDataMap.getHighestNumber("skillRating");
System.out.println("skillRating = " + skillRating.asLong());
. . .
// Update the value. As long as this device is online (or as soon as
// it is), local and cloud storage will be synced.
skillRating.set(MAX_SKILLRATING);
Whispersync maintains a singleton instance of GameDataMap that acts as the root of your game data. It can track numbers, strings, simple lists, and other maps, and gives you complete freedom in how you represent your game data using these basic syncable types. You add new variables simply by retrieving them by name; if they don’t exist, they’ll be created on the fly and synchronized from that point on.
How Whispersync applies updates and resolves conflicts depends on the way you access each syncable variable. For example, suppose you want to retrieve the current user’s best (highest) level across all devices:
GameDataMap gameDataMap = AmazonGamesClient.getWhispersyncClient().getGameData();
SyncableNumber bestLevel = gameDataMap.getHighestNumber("bestLevel" );
System.out.println("bestLevel = " + bestLevel.asLong());
In this case, because “bestLevel” is retrieved as a highest number, it will always reflect the maximum value ever assigned to it, from anywhere, on any device.
String values can also be synced. You could easily set the name of the latest power-up collected:
gameDataMap.getLatestString("lastPowerUp" ).set("Super Boingo");
The syncable string associated with “lastPowerUp” will always reflect the value most recently assigned to it.
The online documentation and Javadoc included with the SDK have more information on the syncable types Whispersync supports, as well as the ways in which each may be accessed.
Practical Applications
Let me provide a few more examples to illustrate just how straightforward synchronization can be.
Recording Star Count for Several Levels
A common pattern in many games is to show stars and unlock status for each level on a level selection screen. Here’s an example of using Whispersync to track individual star values for multiple levels:
SyncableNumber[] getLevelStars() {
GameDataMap gameDataMap = AmazonGamesClient.getWhispersyncClient().getGameData();
SyncableNumber[] stars = new SyncableNumber[NUM_LEVELS];
// Get the star counts for all levels.
for (int i = 0; i < stars.length; i++) {
stars[i] = gameDataMap.getHighestNumber("levelStars" + i);
}
return stars;
}
The first time the score values are accessed (from anywhere), Whispersync will create entries for them in the cloud, so there’s no need to declare them ahead of time.
Accumulating a Running Total
Some progress, like coins collected or time played, represents a running total that is always updated using a delta amount. Instead of setting the value directly, you just submit the amount by which it should change. Whispersync automatically adds or subtracts this value to update the current total. For example:
void addTimePlayedToTotal(long timePlayed) {
GameDataMap gameDataMap = AmazonGamesClient.getWhispersyncClient().getGameData();
SyncableAccumulatingNumber totalTime = gameDataMap.getAccumulatingNumber("totalTime");
// This object may be incremented/decremented with long, double, and
// BigDecimal values.
totalTime.increment(timePlayed);
System.out.println("Total time played: " + totalTime.asLong());
}
Keeping a Map of Maps
By embedding a map within a map, you can create a hierarchical data structure. If your game has multiple worlds, for example, you might keep a separate GameDataMap for each one. Each of these might contain additional maps—say, one for each level.
GameDataMap getWorldData(String name) {
GameDataMap gameDataMap = AmazonGamesClient.getWhispersyncClient().getGameData();
// Get all the string keys associated with top-level GameDataMap objects.
Set<String> worldNames = gameDataMap.getMapKeys();
// Look for a match among the maps.
for (String currentName : worldNames) {
if (currentName.equals(name)) {
// A map exists for the name specified.
return gameDataMap.getMap(currentName);
}
}
// No match found. Don't create one.
return null;
}
Maintaining a List of Numbers
A racing game might save a player’s top three best times using a syncable number list. In this case, you could create a list for low numbers—they decrease as times improve—but you could easily choose to retain a collection of the highest or most recent values in other situations.
// We'll initialize this once the application has launched.
AmazonGames agsGameClient;
GameDataMap agsGameData;
SyncableNumberList agsBestTimes;
// Create a callback to handle initialization result codes.
AmazonGamesCallback agsGameCallback = new AmazonGamesCallback() {
@Override
public void onServiceReady(AmazonGamesClient client) {
agsGameClient = client;
agsGameData = AmazonGamesClient.getWhispersyncClient().getGameData();
// Establish how many slots will be allocated and preserved.
agsBestTimes = agsGameData.getLowNumberList("bestTimes");
agsBestTimes.setMaxSize(3);
}
. . .
};
void finishLap(double time) {
// Every time a lap is completed, try to add the lap time
// to our list of best times. Only the lowest three will
// ever be preserved.
agsBestTimes.add(time);
}
Every time the player finishes a lap, finishLap() would be called to update the number list. The value specified will be discarded unless it is lower than one of the current entries.
More to Come
The GameCircle improvements released in the latest update deserve more attention than the brief overview I’ve given here, so I’ll add details in the coming weeks. Look for articles dedicated to JNI; advanced API features; and using GameCircle with frameworks such as Unity3D, Cocos2d-x, and Marmalade. I’ll also dive deeper into Whispersync, discussing the difference between mergeable and non-mergeable data (and why it’s important), considerations when synchronizing currency across offline devices, and migration from the previous version. Stay tuned!
In the meantime, check out the new GameCircle release and give it a whirl. You’ll benefit whether you use the simplified Whispersync API and its expanded functionality, create a universal build to run on Kindle Fire and Android, or take advantage of the other features included with this update.