How to Save your Game’s Data: Part 1/2

This tutorial will walk you through how to save your game data – locally on the device and also up in the cloud. By Marin Todorov.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Keeping Score

There’s no sense in playing the game if you’re not scoring points, right? Every time the player shoots an asteroid he or she should earn a point and the score should update.

Stay in MyScene.m, scroll to update: and look for the if statement that checks for [shipLaser intersectsNode:asteroid]. This code branch executes whenever a ship laser contacts an asteroid – the code hides the laser and the asteroid, then spawns a new explosion on the position of the impact. Boom!

This is a good spot to add the score-keeping code. Add the following just above the continue; statement at the end of this if block:

[RWGameData sharedGameData].score += 10;
_score.text = [NSString stringWithFormat:@"%li pt", [RWGameData sharedGameData].score];

First, you increase the player’s score by 10 points, then you update the label’s text with the new amount. Build and run, then shoot a few asteroids to try out the scoring system:

sgd_04_HUD_score

If you play a couple of games you’ll notice the score does not reset between games. But why does this happen? You certainly remember writing a reset method in RWGameData, right?

Long story short, you need to add code to invoke reset. Doh!

You want to reset the game data both when the user fails and completes the game. In MyScene.m, just above the line that calls removeAllActions:, add the following line of code inside endTheScene:.

[[RWGameData sharedGameData] reset];

This line will take care of resetting the per-game data every time the game is over. Build and run to see this in action. The score will now reset every time you restart the game.

Next, you’re going to update the high score at the end of every game – but you’ll need to check if the current score is higher than the stored highest score, and update the high score as needed. To make the reward more complex, you’ll need to implement a more complex reward logic that updates the highest score only when the player survives for at least 30 seconds.

The code that detects a successful completion of the game is towards the end of update: in MyScene.m. Find this line [self endTheScene:kEndReasonWin]; and add the following code above it to update the high score:

[RWGameData sharedGameData].highScore = MAX([RWGameData sharedGameData].score,
                                            [RWGameData sharedGameData].highScore);

The MAX() function compares the current score to the high score and sends back the bigger number. You just set [RWGameData sharedGameData].highScore to that number and that’s it. Adding it is easy enough when you know where to put that line in the project’s code.

Next, you’re going to track the distance the space ship travels during a game.

Still inside update: in MyScene.m, add the following code a bit above where you just updated highScore and immediately before the if statement that checks whether _lives <= 0:

static NSTimeInterval _lastCurrentTime = 0;
if (currentTime-_lastCurrentTime>1) {
  [RWGameData sharedGameData].distance++;
  [RWGameData sharedGameData].totalDistance++;
  _distance.text = [NSString stringWithFormat:@"%li miles", [RWGameData sharedGameData].totalDistance];
  _lastCurrentTime = currentTime;
}

update: and currentTime parameters hold the time the method was called. The above code compares currentTime against _lastCurrentTime to see if at least one second passed since it last updated the distance.

To update the distance, you do a few things:

  • You increase the distance traveled this game and the total distance traversed across all games.
  • You update the distance label's text. Note you're displaying the total distance across all games, so it's easy to see if this saves properly when you add that later on.
  • Finally you update _lastCurrentTime, which ensures you won't update distance again until another second has passed.

That's about it. Build and run, and enjoy keeping score and tracking your progress.

sgd_05_HUD_distance

Persisting Data Between Launches

You're doing well so far, but did you notice that every time you launch the game, the high score and the total distance resets to zero? That's not really the result you're after, but it's helpful to take baby steps as you learn how to persist data between app launches.

Your next step will be to make the RWGameData class conform to the NSCoding protocol, which is one good way to persist game data on a device. The advantage of NSCoding over alternative persistence methods like Core Data is that it's nice and easy, and is ideal for a small amount of data like what you see with this game.

The NSCoding protocol declares two required methods:

  • encodeWithCoder:: This method converts your object into a buffer of data. You can think of it as "serializing" your class.
  • initWithCoder:: This method converts a buffer of data into your object. You can think of it as "deserializing" your class.

It's quite simple really - you have to implement one method for saving and one for loading; that's all there is to it. Now, you're going to see how precise data storage can be.

Open RWGameData.h and modify the @interface line so it looks like this:

@interface RWGameData : NSObject <NSCoding>

This declares that RWGameData conforms to the NSCoding protocol.

Switch back to RWGameData.m. You'll add encodeWithCoder: (just think of it as the method to "save" data) and two constants for the key names you'll use to store the data when encoding the class. Add the following code just below the @implementation line:

static NSString* const SSGameDataHighScoreKey = @"highScore";
static NSString* const SSGameDataTotalDistanceKey = @"totalDistance";

- (void)encodeWithCoder:(NSCoder *)encoder
{
  [encoder encodeDouble:self.highScore forKey: SSGameDataHighScoreKey];
  [encoder encodeDouble:self.totalDistance forKey: SSGameDataTotalDistanceKey];
}

encodeWithCoder: receives an NSCoder instance as a parameter. It's up to you to use this to store all the values you need persisted. Note that you'll persist only the high score and total distance. Since the other properties reset between games, there's no need to save them.

You probably already figured out how the encoding works. You call a method called encodeXxx:forKey: and provide a value and a key name, based on the type of your data. There are methods for encoding all the primitive types, like doubles, integers or booleans.

There's also a method to encode any object that supports NSCoding (encodeObject:). Many of the built-in classes like NSString, NSArray or NSDictionary implement NSCoding. You can always implement NSCoding on your own objects, much like how you're doing it here.

Note: If your class extends anything that conforms to the NSCoding protocol, you must call [super encodeWithCoder:encoder]; to ensure all of your object's data persists.

This is everything you need to do in this method, just supply values and keys to the encoder. Actually saving to the device is a separate task on your list, which you'll take on in a moment.

Now it's time to implement the opposite process - initializing a new instance with the data from a decoder. Add the following method to RWGameData.m:

- (instancetype)initWithCoder:(NSCoder *)decoder
{
  self = [self init];
  if (self) {
    _highScore = [decoder decodeDoubleForKey: SSGameDataHighScoreKey];
    _totalDistance = [decoder decodeDoubleForKey: SSGameDataTotalDistanceKey];
  }
  return self;
}

See how you start this method much as you would any other initializer, by calling some initializer on your parent class? If your class extends anything that conforms to the NSCoding protocol, you most likely need to call [super initWithCoder:decoder], but because this class extends NSObject, calling init here is fine.

Much the same way you used encodeDouble:forKey: to store a double value for a given key, you now use decodeDoubleForKey: to retrieve a double value from the NSCoder instance passed into the method.

By implementing these two methods, you added the ability to your class to save its current state and retrieve it with ease.

sgd_06_ohyeah

I'm sure you're eager to take your new NSCoding class for a test drive. However, you'll need to hold your horses just a bit longer. You still need to save the game data to a file.

First, you need to make sure the class will create a new empty instance whenever there's no persisted data, i.e. the very first time you run the game.

Inside RWGameData.m, add this simple helper method to construct the file path to store the game data:

+(NSString*)filePath
{
  static NSString* filePath = nil;
  if (!filePath) {
    filePath = 
      [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]
       stringByAppendingPathComponent:@"gamedata"];
  }
  return filePath;
}

This method uses NSSearchPathForDirectoriesInDomains() to obtain the path to the app's document directory, then adds to it a file name of "gamedata", thus returning a fully qualified file path.

Next, you're going to use this method to check if there's a saved game data file already, and if so, load it and create a new class instance from it. Add this new method to RWGameData.m:

+(instancetype)loadInstance
{
  NSData* decodedData = [NSData dataWithContentsOfFile: [RWGameData filePath]];
  if (decodedData) {
    RWGameData* gameData = [NSKeyedUnarchiver unarchiveObjectWithData:decodedData];
    return gameData;
  }
  
  return [[RWGameData alloc] init];
}

First you get the file path where the stored game data file should be and then you try to create an NSData instance out of the file contents.

If decodedData is not nil, then that means the file content was read successfully and converted to an NSData instance. In that case (pay attention…here comes the magic) you create an RWGameData instance by calling NSKeyedUnarchiver's class method unarchiveObjectWithData:.

What unarchiveObjectWithData: does is to try to initialize a new RWGameData by invoking its initWithCoder: initializer with an NSCoder loaded with decodedData. (Try saying that aloud, three times, fast; it's a mouthful!)

In case decodedData was nil you just construct a new instance of the class by calling init.

One final touch for RWGameData.m - in sharedGameData, replace this line:

sharedInstance = [[self alloc] init];

with this one:

sharedInstance = [self loadInstance];

This will ensure that when you create the instance of the game data class it loads the contents of the previously stored file, provided one exists at the target file path.

I don't always write a load method, but when I do I write a save method too.

sgd_07_sir

That's a good piece of advice just there. Add a save method to RWGameData.m, as well:

-(void)save
{
  NSData* encodedData = [NSKeyedArchiver archivedDataWithRootObject: self];
  [encodedData writeToFile:[RWGameData filePath] atomically:YES];
}

This code is the exact reverse of what you implemented in loadInstance. First, you call [NSKeyedArchiver archivedDataWithRootObject:] to get encoded data out of your class instance; this calls encodeWithCoder: on your instance behind the scenes. Then writing the data to file is simply a matter of calling writeToFile:atomically:.

Remember to switch to RWGameData.h, and also to add the method signature inside the class interface:

-(void)save;

And that's all there is to archiving and unarchiving data using the device. Now you only need to call save every now and then, and you're good to go.

Open MyScene.m and add the following line to endTheScene:, just before the line that calls reset on the RWGameData singleton:

[[RWGameData sharedGameData] save];

This wraps up this section, so now it's time to give your game a try! Build and run, then play a few to see your high score persist between launches:

sgd_08_endlevel

Then stop the game from Xcode and launch the project again. When you start the game you'll see the high score you achieved during the previous launch. Success!

sgd_09_highscoresaved