iOS Components and Frameworks: Understanding the Advanced Features of the iOS SDK (2014)
Chapter 3. Leaderboards
Leaderboards have become an important component of nearly every mobile game, as well as having numerous applications outside of gaming. Leveraging Game Center to add leaderboards makes it easier than it has ever been in the past to include them in an iOS app. Although leaderboards have been around almost as long as video games themselves—the first high-score list appeared in 1976—they have more recently become critical parts of a social gaming experience. In this chapter you will learn how to add a full-featured leaderboard to a real-world game, from setting up the required information in iTunes Connect to displaying the leaderboard using GKLeaderboardViewController.
Whack-a-Cac
In this chapter and in Chapter 4, “Achievements,” the same sample game will be used. It is important that you are familiar with the game itself so that you can remove the complexity of it from the process of integrating Game Center. Whack-a-Cac was designed to use minimal code and be simple to learn, so the game can act as a generic placeholder for whatever app you are integrating Game Center into. If you already have an app that you are ready to work with, skip this section and follow along with your existing project.
Whack-a-Cac, as shown in Figure 3.1, is a simple whack-a-mole type of game. Cacti will pop up and you must tap them before the timer fires and they go back into the sand dunes. As the game progresses it continues to get more difficult, and after you have missed five cacti the game ends and you are left with a score. The game itself is controlled through ICFGameViewController. Cacti can appear anywhere along the x-axis and on one of three possible rows on the y-axis. The player will have two seconds from the time a cactus appears to tap it before it goes back down and the player is deducted a life. Up until a score of 50, every 10 cacti that are hit will increase the maximum number shown at once by one. The game can also be paused and resumed.
Figure 3.1 A first look at Whack-a-Cac, the game that is used for the Game Center chapters of this book.
Before you can dig into the game-specific code, attention must first be paid to the home screen of the game. IFCViewController.m will not only handle launching the gameplay but also enable the user to access the leaderboards as well as the achievements. The Game Center-specific functionality of this class is discussed in detail later in this chapter, but for the time being the focus should be on the play: method. When a new game is created, IFCViewController simply allocs and inits a new copy of IFCGameViewController and pushes it onto the navigation stack. The rest of the game will be handled by that class.
Whack-a-Cac is a very simplified example of an iOS game. It is based on a state engine containing three states: playing, paused, and game over. While the game is playing, the engine will continue to spawn cacti until the user runs out of life and enters the game-over state. The user can pause the game at any time by tapping the pause button in the upper-left corner of the screen. To begin, direct your attention to the viewDidLoad: method.
- (void)viewDidLoad
{
[[ICFGameCenterManager sharedManager] setDelegate: self];
score = 0;
life = 5;
gameOver = NO;
paused = NO;
[super viewDidLoad];
[self updateLife];
[self spawnCactus];
[self performSelector:@selector(spawnCactus) withObject:nil afterDelay:1.0];
}
On the first line ICFGameCenterManager has its delegate set to self, which is covered in depth later in this section. During the viewDidLoad execution, a few state variables are set. First the score is reset to zero along with the life being set to five. The next requirement is setting two Booleans that represent the current state of the game; since the game starts with gameplay on launch, both gameOver and paused are set to NO. A method named updateLife is called. Although this is discussed later in this section, for now just note that it handles rendering the player’s lives to the upper right of the screen, as shown in Figure 3.1. The final initialization step is to spawn two cacti at the start of the game. One is spawned instantly and the other is delayed by one second.
Spawning a Cactus
One of the most important functions in IFCGameViewController is spawning the actual cacti to the screen. In the viewDidLoad method spawnCactus was called twice; turn your attention to that method now. In a state-based game the first thing that should be done is to check to make sure you are in the correct state. The first test that is run is a gameOver check; if the game has ended, the cactus should stop spawning. The next check is a pause test; if the game is paused, you don’t want to spawn a new cactus either, but when the game is resumed you want to start spawning again. The test performed on the paused state will retry spawning every second until the game is resumed or quit.
if(gameOver)
{
return;
}
if(paused)
{
[self performSelector:@selector(spawnCactus) withObject:nil afterDelay:1];
return;
}
If the method has passed both the state checks, it is time to spawn a new cactus. First the game must determine where to randomly place the object. To randomly place the cactus in the game, two random numbers are generated, the first for the row to be spawned in, followed by the x-axis location.
int rowToSpawnIn = arc4random()%3;
int horizontalLocation = (arc4random()%1024);
To create a more interesting gaming experience, there are three different images for the cactus. With each new cactus spawned, one image is randomly selected through the following code snippet:
int cactusSize = arc4random()%3;
UIImage *cactusImage = nil;
switch (cactusSize)
{
case 0:
cactusImage = [UIImage imageNamed:
@"CactusLarge.png"];
break;
case 1:
cactusImage = [UIImage imageNamed: @"CactusMed.png"];
break;
case 2:
cactusImage = [UIImage imageNamed:
@"CactusSmall.png"];
break;
default:
break;
}
A simple check is performed next to make sure that the cactus is not being drawn off the right side of the view. Since the x-axis is calculated randomly and the widths of the cacti are variable, a simple if statement tests to see whether the image is being drawn too far right and if so moves it back to the edge.
if(horizontalLocation > 1024 - cactusImage.size.width)
horizontalLocation = 1024 - cactusImage.size.width;
Whack-a-Cac is a depth- and layer-based game. There are three dunes, and a cactus should appear behind the dune for the row it is spawned in but in front of dunes that fall behind it. The first step in making this work is to determine which view the new cactus needs to fall behind. This is done using the following code snippet:
UIImageView *duneToSpawnBehind = nil;
switch (rowToSpawnIn)
{
case 0:
duneToSpawnBehind = duneOne;
break;
case 1:
duneToSpawnBehind = duneTwo;
break;
case 2:
duneToSpawnBehind = duneThree;
break;
default:
break;
}
Now that the game knows where it needs to layer the new cactus, a couple of convenience variables are created to increase the readability of the code.
float cactusHeight = cactusImage.size.height;
float cactusWidth = cactusImage.size.width;
All the important groundwork has now been laid, and a new cactus can finally be placed into the game view. Since the cactus will act as a touchable item, it makes sense to create it as a UIButton. The frame variables are inserted, which cause the cactus to be inserted behind the dune and thus be invisible. An action is added to the cactus that calls the method cactusHit:, which is discussed later in this section.
UIButton *cactus = [[UIButton alloc] initWithFrame:CGRectMake(horizontalLocation, (duneToSpawnBehind.frame.origin.y), cactusWidth, cactusHeight)];
[cactus setImage:cactusImage forState: UIControlStateNormal];
[cactus addTarget:self action:@selector(cactusHit:) forControlEvents:UIControlEventTouchDown];
[self.view insertSubview:cactus belowSubview:duneToSpawnBehind];
Now that a cactus has been spawned, it is ready to be animated up from behind the dunes and have its timer started to inform the game when the user has failed to hit it within two seconds. The cactus will slide up from behind the dune in a 1/4th-second animation using information about the height of the cactus to make sure that it ends up in the proper spot. A two second timer is also begun that will fire cactusMissed:, which is discussed in the “Cactus Interaction” section.
[UIView beginAnimations: @"slideInCactus" context:nil];
[UIView setAnimationCurve: UIViewAnimationCurveEaseInOut];
[UIView setAnimationDuration: 0.25];
cactus.frame = CGRectMake(horizontalLocation, (duneToSpawnBehind.frame.origin.y)-cactusHeight/2, cactusWidth, cactusHeight);
[UIView commitAnimations];
[self performSelector:@selector(cactusMissed:) withObject:cactus afterDelay:2.0];
Cactus Interaction
There are two possible outcomes of a cactus spawning: Either the user has hit the cactus within the two-second limit or the user has failed to hit it. In the first scenario cactusHit: is called in response to a UIControlEventTouchDown on the cactus button. When this happens, the cactus is quickly faded off the screen and then removed from the superView. Using the option UIViewAnimationOptionsBeginFromCurrentState will ensure that any existing animations on this cactus are cancelled. The score is incremented by one anddisplayNewScore: is called to update the score on the screen; more on that later in this section. After a cactus has been hit, a key step is spawning the next cactus. This is done in the same fashion as in viewDidLoad but with a randomized time to create a more engaging experience.
- (IBAction)cactusHit:(id)sender;
{
[UIView animateWithDuration:0.1
delay:0.0
options: UIViewAnimationCurveLinear |
UIViewAnimationOptionBeginFromCurrentState
animations:^
{
[sender setAlpha: 0];
}
completion:^(BOOL finished)
{
[sender removeFromSuperview];
}];
score++;
[self displayNewScore: score];
[self performSelector:@selector(spawnCactus) withObject:nil afterDelay:(arc4random()%3) + .5];
}
Two seconds after any cactus are spawned, the cactusMissed: method will be called, even on cacti that have been hit. Since this method is called regardless of whether it has been hit already, it is important to provide a state check. The cactus was removed from the superView when it was hit, and therefore it will no longer have a superView. A simple nil check and a quick return prevent the user from losing points for cactus that were successfully hit.
You also don’t want to penalize the player for pausing the game, so while the game is in the pause state, the user should not lose any life. If the method has gotten this far without returning, you know that the user has missed a cactus and needs to be penalized. As with the cactusHit:method, the game still needs to remove this missed cactus from the superView and start a timer to spawn a replacement. In addition, instead of incrementing the score, you need to decrement the user’s life, and a call to updateLife is performed to update the display.
Note
The pause-state approach here creates an interesting usability bug. If you pause the game, the cactus that would have disappeared while paused will remain on the screen forever. Although there are ways to resolve this bug, for the sake of example simplicity this weakened experience was left in the game.
- (void)cactusMissed:(UIButton *)sender;
{
if([sender superview] == nil)
{
return;
}
if(paused)
{
return;
}
CGRect frame = sender.frame;
frame.origin.y += sender.frame.size.height;
[UIView animateWithDuration:0.1
delay:0.0
options: UIViewAnimationCurveLinear |
UIViewAnimationOptionBeginFromCurrentState
animations:^
{
sender.frame = frame;
}
completion:^(BOOL finished)
{
[sender removeFromSuperview];
[self performSelector:@selector(spawnCactus)
withObject:nil afterDelay:(arc4random()%3) + .5];
life--;
[self updateLife];
}];
}
Displaying Life and Score
What fun would Whack-a-Cac be with no penalties for missing and no way to keep track of how well you are doing? Displaying the user’s score and life are crucial game-play elements in our sample game, and both methods have been called from methods that have been looked at earlier in this section.
Turn your focus now to displayNewScore: in IFCGameViewController.m. Anytime the score is updated, a call to displayNewScore: is necessary to update the score display in the game. In addition to displaying the score, every time the score reaches a multiple of 10 while less than or equal to 50, a new cactus is spawned. This new cactus spawning has the effect of increasing the difficulty of the game as the player progresses.
- (void)displayNewScore:(float)updatedScore;
{
int scoreInt = score;
if(scoreInt % 10 == 0 && score <= 50)
{
[self spawnCactus];
}
scoreLabel.text = [NSString stringWithFormat: @"%06.0f",
updatedScore];
}
Displaying life is similar to displaying the user’s score but with some additional complexity. Instead of a text field being used to display a score, the user’s life is represented with images. After a UIImage is created to represent each life, the first thing that must be done is to remove the existing life icons off the view. This is done with a simple tag search among the subviews. Next, a loop is performed for the number of lives the user has left, and each life icon is drawn in a row in the upper right of the game view. Finally, the game needs to check that the user still has some life left. If the user has reached zero life, a UIAlert informs him that the game has ended and what his final score was.
-(void)updateLife
{
UIImage *lifeImage = [UIImage imageNamed:@"heart.png"];
for(UIView *view in [self.view subviews])
{
if(view.tag == kLifeImageTag)
{
[view removeFromSuperview];
}
}
for (int x = 0; x < life; x++)
{
UIImageView *lifeImageView = [[UIImageView alloc]
initWithImage: lifeImage];
lifeImageView.tag = kLifeImageTag;
CGRect frame = lifeImageView.frame;
frame.origin.x = 985 - (x * 30);
frame.origin.y = 20;
lifeImageView.frame = frame;
[self.view addSubview: lifeImageView];
[lifeImageView release];
}
if(life == 0)
{
gameOver = YES;
UIAlertView *alert = [[UIAlertView alloc]
initWithTitle:@"Game Over!"
message: [NSString stringWithFormat: @"You scored %0.0f points!", score]
delegate:self
cancelButtonTitle:@"Dismiss"
otherButtonTitles:nil];
alert.tag = kGameOverAlert;
[alert show];
[alert release];
}
}
Pausing and Resuming
Whack-a-Cac enables the user to pause and resume the game using the pause button in the upper-left corner of the game view. Tapping that button calls the pause: action. This method is very simple: The state variable for paused is set to YES and an alert asking the user to exit or resume is presented.
- (IBAction)pause:(id)sender
{
paused = YES;
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@""
message:@"Game Paused!"
delegate:self
cancelButtonTitle:@"Exit"
otherButtonTitles:@"Resume", nil];
alert.tag = kPauseAlert;
[alert show];
[alert release];
}
Game over and pause both use UIAlerts to handle user responses. In the event of game over or the exit option in pause, the navigation stack is popped back to the menu screen. If the user has resumed the game, all that needs to be done is to set the pause state back to NO.
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex
{
if(alertView.tag == kGameOverAlert)
{
[self.navigationController popViewControllerAnimated: YES];
}
else if (alertView.tag == kPauseAlert)
{
if(buttonIndex == 0)
{
[self.navigationController popViewControllerAnimated: YES];
}
else
{
paused = NO;
}
}
}
Final Thoughts on Whack-a-Cac
You should now be very familiar and confident with the gameplay and functionality of Whack-a-Cac. Although there are some additional cleanup methods that might warrant a look in the source code, depending on your usage of ARC they might not be needed. In the following sections you will learn how to add leaderboards into Whack-a-Cac.
iTunes Connect
Leaderboard data is stored on Apple’s Game Center servers. To configure your app to interact with leaderboards, you must first properly configure everything in iTunes Connect. Using the iTunes Connect Portal (http://itunesconnect.apple.com), create a new app as you would when submitting an app for sale. If you already have an existing app, you can use that as well. After you have populated the basic information, your app page should look similar to the one shown in Figure 3.2.
Figure 3.2 A basic app page as seen through iTunes Connect.
Warning
From the time that you create a new app in iTunes Connect, you have 90 days to submit it for approval. This policy was enacted to prevent people from name squatting app names. Although you can work around this by creating fake test apps to hook the Game Center to for testing, it is an important limitation to keep in mind.
Direct your attention to the upper-right corner of the app page, where you will find a button called Manage Game Center. This is where you will configure all the Game Center behavior for your app. Inside the Game Center configuration page the first thing you will notice is a slider to enable Game Center, as shown in Figure 3.3. Additionally, with iOS 6 and newer there is now the option of using shared leaderboards across multiple apps, such as a free and paid version. To set up shared group leaderboards, you will need to create a reference name that then can be shared across multiple apps associated with your iTunes Connect account. This configuration is done under the Move to Group option after a leaderboard has been created.
Figure 3.3 Enabling Game Center behavior in your app.
After you have enabled Game Center functionality for your app, you will need to set up the first leaderboard as shown in Figure 3.4. It is important to note that after an app has been approved and made live on the App Store, you cannot delete that leaderboard anymore. Apple has also recently provided an option to delete test data for your leaderboard. It is recommended to wipe your test data that was generated during testing before shipping your app.
Figure 3.4 Setting up a new leaderboard.
After you have selected Add Leaderboard, you will be presented with two options. The first, Single Leaderboard, is for a standalone leaderboard, like the type used in Whack-a-Cac. The Single Leaderboard will store a set of scores for your app or a game mode within your app. The second option is for a Combined Leaderboard; this option enables you to combine two or more Single Leaderboards to create an ultimate high-score list. For example, if you have a leaderboard for each level of your game, you can create a combined leaderboard to see the top score across all levels at once. For the purpose of this chapter, you will be working only with a Single Leaderboard.
Note
Apple currently limits the number of leaderboards allowed per app to 25.
When setting up a leaderboard, you will be required to enter several fields of information, as shown in Figure 3.5. The first entry is the Leaderboard Reference Name. This field is entirely used by you within iTunes Connect to be able to quickly locate and identify the leaderboard. Leaderboard ID is the attribute that will be used to query the leaderboard within the actual project. Apple recommends using a reverse DNS system. The Leaderboard ID used for Whack-a-Cac was com.dragonforged.whackacac.leaderboard; if you are working with your own app, be sure to substitute whatever entry you have here in all following code examples as well.
Figure 3.5 Configuring a standard single leaderboard in iTunes Connect for Whack-a-Cac.
Apple has provided several preset options for formatting the score data in the leaderboard list. In Table 3.1 examples for each type of formatting are provided.
Table 3.1 A Detailed Breakdown of Available Score Formatting Options and Associated Sample Output
Note
If your score doesn’t conform to one of the formats shown in Table 3.1, all is not lost; however, you will be required to work with custom leaderboard presentation. Retrieving raw score values is discussed in the section “Going Further with Leaderboards.”
The sort-order option controls whether Game Center shows the highest score at the top of the chart or the lowest score. Additionally, you can specify a score range that will automatically drop scores that are too high or too low from being accepted by Game Center.
The final step when creating a new leaderboard is to add localization information. This is required information and you will want to provide at a minimum localized data for the app’s primary language; however, you can also provide information for additional languages that you would like to support. In addition to the localized name, you have the option to fine-tune the score format, associate an image with this leaderboard, and enter the suffix to be used. When you’re entering the score suffix, it is important to note that you might need a space before the entry because Game Center will print whatever is here directly after the score. For example, if you enter “Points” your score output will look like “123Points” instead of “123 Points.”
After you’ve finished entering all the required data, you will need to hit the Save button for the changes to take effect. Even after you save, it might take several hours for the leaderboard information to become available for use on Apple’s servers. Now that you have a properly configured Game Center leaderboard, you can return to your Xcode project and begin to set up the required code to interact with it.
Game Center Manager
When you are working with Game Center, it is very likely that you will have multiple classes that need to directly interact with a single shared manager. In addition to the benefits of isolating Game Center into its own manager class, it makes it very easy to drop all the required Game Center support into new projects to quickly get up and rolling. In the Whack-a-Cac project turn your attention to the IFCGameCenterManager class. The first thing you might notice in this class is that it is created around a singleton, which means that you will have only one instance of it in your app at any given time. The first thing that needs to be done is to create the foundation of the Game Center manager that you will be building on top of. The Game Center manager will handle all the direct Game Center interaction and will use a protocol to send a delegate information regarding the successes and failures of these calls. Since Game Center calls are not background thread safe, the manager will need to direct all delegate callbacks onto the main thread. To accomplish this, you have two new methods. The first will ensure that it is using the main thread to create callbacks with.
- (void)callDelegateOnMainThread:(SEL)selector withArg:(id)arg error:(NSError*)error
{
dispatch_async(dispatch_get_main_queue(), ^(void)
{
[self callDelegate: selector withArg: arg error: error];
});
}
The callDelegateOnMainThread: method will pass along all arguments and errors into the callDelegate: method. The first thing the callDelegate method does is ensure that it is being called from the main thread, which it will be if it is never called directly. Since thecallDelegate method does not function correctly without a delegate being set, this is the next check that is performed. At this point it is clear that we are on the main thread and have a delegate. Using respondsToSelector: you can test whether the proper delegate method has been implemented; if it has not, some helpful information is logged as shown in this example.
2012-07-28 17:12:41.816 WhackACac[10121:c07] Unable to find delegate method 'gameCenterLoggedIn:' in class ICFViewController
When all the safety and sanity tests have been performed, the delegate method is called with the required arguments and error information. Now that a basic delegate callback system is in place, you can begin working with actual Game Center functionality.
- (void)callDelegate: (SEL)selector withArg: (id)arg error:(NSError*)error
{
assert([NSThread isMainThread]);
if(delegate == nil)
{
NSLog(@"Game Center Manager Delegate has not been set");
return;
}
if([delegate respondsToSelector: selector])
{
if(arg != NULL)
{
[delegate performSelector: selector withObject:
arg withObject: error];
}
else
{
[delegate performSelector: selector withObject:
error];
}
}
else
{
NSLog(@"Unable to find delegate method '%s' in class %@", sel_getName(selector), [delegate class]);
}
}
Authenticating
Game Center is an authenticated service, which means that you cannot do anything but authenticate when you are not logged in. With this in mind you must first authenticate before being able to proceed with any of the leaderboard relevant code. Authenticating with Game Center is handled mostly by iOS for you. The following code will present a UIAlert allowing the user to log in to Game Center or create a new Game Center account.
Note
Do not forget to include the GameKit.framework and import GameKit/GameKit.h whenever you are working with Game Center.
- (void) authenticateLocalUser
{
if([GKLocalPlayer localPlayer].authenticated == NO)
{
[[GKLocalPlayer localPlayer] authenticateWithCompletionHandler: ^(NSError *error)
{
if(error != nil)
{
NSLog(@"An error occurred: %@", [error localizedDescription]);
return;
}
[self callDelegateOnMainThread: @selector(gameCenterLoggedIn:) withArg: NULL error: error];
}];
}
}
In the event that an error occurs it is logged to the console. If the login completes without error, a delegate method is called. You can see how these delegate methods are set up in the ICFGameCenterManager.h file.
Common Authentication Errors
There are several common cases that can be helpful to catch when dealing with authentication errors. The following method is a modified version of authenticateLocalUser with additional error handling built in.
Note
If you are receiving an alert that says your game is not recognized by Game Center, check to make sure that the bundle ID of your app matches the one configured in iTunes Connect. A new app might take a few hours to have Game Center fully enabled, and a lot of Game Center problems can be resolved by waiting a little while and retrying.
- (void) authenticateLocalUser
{
if([GKLocalPlayer localPlayer].authenticated == NO)
{
[[GKLocalPlayer localPlayer] authenticateWithCompletionHandler:^(NSError *error)
{
if(error != nil)
{
if([error code] == GKErrorNotSupported)
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message:@"This device does not support Game Center" delegate:nil cancelButtonTitle:@"Dismiss" otherButtonTitles:nil];
[alert show];
[alert release];
}
else if([error code] == GKErrorCancelled)
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message:@"This device has failed login too many times from the app; you will need to log in from the Game Center.app" delegate:nil cancelButtonTitle:@"Dismiss"otherButtonTitles:nil];
[alert show];
[alert release];
}
else
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message: [error localizedDescription] delegate:nil cancelButtonTitle:@"Dismiss" otherButtonTitles:nil];
[alert show];
[alert release];
}
return;
}
[self callDelegateOnMainThread: @selector(gameCenterLoggedIn:) withArg: NULL error: error];
}];
}
}
In the preceding example three additional error cases are handled. The first error that is caught is when a device does not support Game Center for any reason. In this event a UIAlert is presented to the user informing her that she is unable to access Game Center. The second error rarely appears in shipping apps but can be a real headache when debugging; if your app has failed to log in to Game Center three times in a row, Apple disables its capability to log in. In this case you must log in from the Game Center.app. The third error case is a catchall for any additional errors to provide information to the user.
Upon successful login, your user is shown a login message from Game Center. This message will also inform you of whether you are currently in a Sandbox environment, as shown in Figure 3.6.
Figure 3.6 A successful login to Game Center from the Whack-a-Cac sample game.
Note
Any nonshipping app will be in the Sandbox environment. After your app is downloaded from the App Store, it will be in a normal production environment.
iOS 6 Authentication
Although the preceding method of authentication continues to work on iOS 6, Apple has introduced a new streamlined approach to handling authentication on apps that do not need to support iOS 5 or older.
With the new approach, an authenticateHandler block is now used. Errors are captured in the same manner as in the previous examples, but now a viewController can be passed back to your application by Game Center. In the case in which the viewController parameter ofthe authenticateHandler block is not nil, you are expected to display the viewController to the user.
The first time a new authenticateHandler is set, the app will automatically authenticate. Additionally, the app will automatically reauthenticate on return to the foreground. If you do need to call authenticate manually, you can use the authenticate method.
-(void)authenticateLocalUseriOSSix
{
if([GKLocalPlayer localPlayer].authenticateHandler == nil)
{
[[GKLocalPlayer localPlayer] setAuthenticateHandler:^(UIViewController *viewController, NSError *error)
{
if(error != nil)
{
if([error code] == GKErrorNotSupported)
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message:@"This device does not support Game Center" delegate:nil cancelButtonTitle:@"Dismiss" otherButtonTitles:nil];
[alert show];
[alert release];
}
else if([error code] == GKErrorCancelled)
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message:@"This device has failed login too many times from the app; you will need to log in from the Game Center.app" delegate:nil cancelButtonTitle:@"Dismiss"otherButtonTitles:nil];
[alert show];
[alert release];
}
else
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Error" message:[error localizedDescription] delegate:nil cancelButtonTitle:@"Dismiss" otherButtonTitles:nil];
[alert show];
[alert release];
}
}
else
{
if(viewController != nil)
{
[(UIViewController *)delegate presentViewController:viewController animated:YES completion: nil];
}
}
}];
}
else
{
[[GKLocalPlayer localPlayer] authenticate];
}
}
Submitting Scores
When authenticated with Game Center, you are ready to begin submitting scores. In the IFCGameCenterManager class there is a method called reportScore:forCategory. This allows you to post a new score for the Leaderboard ID that was configured in iTunes Connect. All new scores are submitted by creating a new GKScore object; this object holds onto several values such as the score value, playerID, date, rank, formattedValue, category, and context.
When a new score is submitted, most of this data is automatically populated. The value and category are the only two required fields. An optional context can be provided which is an arbitrary 64-bit unsigned integer. A context can be used to store additional information about the score, such as game settings or flags that were on when the score was achieved; it can be set and retrieved using the context property. The date, playerID, formattedValue, and rank are read-only and are populated automatically when the GKScore object is created or retrieved.
Note
Leaderboards support default categories when being set in iTunes Connect. If a score is being submitted to a default leaderboard, the category parameter can be left blank. It is best practice to always include a category argument to prevent hard-to-track-down bugs.
After a new GKScore object has been created using the proper category for the leaderboard, you can assign a raw score value. When dealing with integers or floats, the score is simply the number of the score. When dealing with elapsed time, however, the value should be submitted in seconds or seconds with decimal places if tracking that level of accuracy.
When a score has been successfully submitted, it will call gameCenterScoreReported: on the GameCenterManager delegate. This is discussed in more detail in the next section, “Adding Scores to Whack-a-Cac.”
- (void)reportScore:(int64_t)score forCategory:(NSString*)category
{
GKScore *scoreReporter = [[[GKScore alloc]
initWithCategory:category] autorelease];
scoreReporter.value = score;
[scoreReporter reportScoreWithCompletionHandler: ^(NSError *error)
{
if (error != nil)
{
NSData* savedScoreData = [NSKeyedArchiver archivedDataWithRootObject:scoreReporter];
[self storeScoreForLater: savedScoreData];
}
[self callDelegateOnMainThread:@selector (gameCenterScoreReported:) withArg: NULL error: error];
}];
}
It is important to look at the failure block of reportScoreWithCompletionHandler. If a score fails to successfully transmit to Game Center, it is your responsibility as the developer to attempt to resubmit this score later. There are few things more frustrating to a user than losing a high score due to a bug or network failure. In the preceding code example, when a score has failed, NSKeyedArchiver is used to create a copy of the object as NSData and passed to storeScoreForLater:. It is critical that the GKScore object itself is used, and not just the score value. Game Center ranks scores by date if the scores match; since the date is populated automatically when creating a new GKScore, the only way to not lose the player’s info is to archive the entire GKScore object.
When saving the score data, the sample app uses the NSUserDefaults; this data could also be easily stored into Core Data or any other storage system. After the score is saved, it is important to retry sending that data when possible. A good time to do this is when Game Center successfully authenticates.
- (void)storeScoreForLater:(NSData *)scoreData;
{
NSMutableArray *savedScoresArray = [[NSMutableArray alloc] initWithArray: [[NSUserDefaults standardUserDefaults]
objectForKey:@"savedScores"]];
[savedScoresArray addObject: scoreData];
[[NSUserDefaults standardUserDefaults] setObject:savedScoresArray forKey:@"savedScores"];
[savedScoresArray release];
}
The attempt to resubmit the saved scores is no different than submitting a score initially. First the scores need to be retrieved from the NSUserDefaults, and since the object was stored in NSData, that data needs to be converted back into a GKScore object. Once again, it is important to catch failed submissions and try them again later.
-(void)submitAllSavedScores
{
NSMutableArray *savedScoreArray = [[NSMutableArray alloc] initWithArray: [[NSUserDefaults standardUserDefaults] objectForKey:@"savedScores"]];
[[NSUserDefaults standardUserDefaults] removeObjectForKey: @"savedScores"];
for(NSData *scoreData in savedScoreArray)
{
GKScore *scoreReporter = [NSKeyedUnarchiver unarchiveObjectWithData: scoreData];
[scoreReporter reportScoreWithCompletionHandler: ^(NSError *error)
{
if (error != nil)
{
NSData* savedScoreData = [NSKeyedArchiver archivedDataWithRootObject: scoreReporter];
[self storeScoreForLater: savedScoreData];
}
else
{
NSLog(@"Successfully submitted scores that were pending submission");
[self callDelegateOnMainThread: @selector(gameCenterScroreReported:) withArg:NULL error:error];
}
}];
}
}
Tip
If a score does fail to submit, it is always a good idea to inform the user that the app will try to submit again later; otherwise, it might seem as though the app has failed to submit the score and lost the data for the user.
Adding Scores to Whack-a-Cac
In the preceding section the Game Center Manager component of adding scores to an app was explored. In this section you will learn how to put these additions into practice in Whack-a-Cac. Before proceeding, Game Center must first authenticate a user and specify a delegate. Modify theviewDidLoad method of IFCViewController.m to complete this process.
- (void)viewDidLoad
{
[super viewDidLoad];
[[ICFGameCenterManager sharedManager] setDelegate: self];
[[ICFGameCenterManager sharedManager] authenticateLocalUser];
}
IFCViewController will also need to respond to the GameCenterManagerDelegate. The first delegate method that needs to be handled is gameCenterLoggedIn:. Since the GameCenterManager is handling all the UIAlerts associated with informing the user of failures, any errors here are simply logged for debugging purposes.
- (void)gameCenterLoggedIn:(NSError*)error
{
if(error != nil)
{
NSLog(@"An error occurred trying to log into Game Center: %@", [error localizedDescription]);
}
else
{
NSLog(@"Successfully logged into Game Center!");
}
}
After the user has decided to begin a new game of Whack-a-Cac, it is important to update the GameCenterManager’s delegate to the IFCGameViewController class. In Whack-a-Cavc the delegate will always be set to the front-most view to simplify providing user feedback and errors. This is done by adding the following line of code to the viewDidLoad: method of IFCGameViewController. Don’t forget to declare this class as conforming to GameCenterManagerDelegate.
[[ICFGameCenterManager sharedManager] setDelegate: self];
The game will need to submit a score under two scenarios: when the user loses a game, and when the user quits from the pause menu. Using the IFCGameCenterManager, submitting a score is very easy. Add the following line of code to both the test for zero life in the updateLifemethod and the exit button action on the pause UIAlert:
[[ICFGameCenterManager sharedManager]reportScore: (int64_t)scoreforCategory: @"com.dragonforged.whackacac.leaderboard"];
Note
The category ID you set for your leaderboard might differ from the one used in these examples. Be sure that the one used matches the ID that appears in iTunes Connect.
While the GameCenterManager reportScore: method will handle submitting the scores and all error recovery, it is important to add the delegate method gameCenterScoreReported: to watch for potential errors and successes.
-(void)gameCenterScoreReported:(NSError *)error;
{
if(error != nil)
{
NSLog(@"An error occurred trying to report a score to Game Center: %@", [error localizedDescription]);
}
else
{
NSLog(@"Successfully submitted score");
}
}
Note
Scores should be submitted only when finalized; sending scores to Game Center at every increment can create a poor user experience.
When a user is exiting the game, the delegate for GameCenterManager will disappear while the network operations for submitting the score are still taking place. It is important to have IFCViewController reset the GameCenterManagerDelegate to SELF and implement thegameCenterScoreReported: delegate as well.
Presenting Leaderboards
A new high score is not of much use to your users if they are unable to view their high scores. In this section you will learn how to use Apple’s built-in view controllers to present the leaderboards. The leaderboard view controllers saw significant improvements with the introduction of iOS 6, as shown in Figure 3.7. In previous versions of iOS, leaderboards and achievements were handled by two separate view controllers; these have now been combined. In addition, a new section for Game Center challenges and Facebook liking have been added.
Figure 3.7 The GKLeaderboardViewController saw major improvements and changes in iOS 6.
In ICFViewController there is a method called leaderboards: that handles presenting the leaderboard view controllers. When a new GKLeaderboardViewController is being created, there are a couple of properties that need to be addressed. First set the category. This is the same category that is used for submitting scores and refers to which leaderboard should be presented to the user. A timeScope can also be provided, which will default the user onto the correct tab as shown in Figure 3.7. In the following example the time scope for all time is supplied. Additionally, a required leaderboardDelegate must be provided; this delegate will handle dismissing the leaderboard modal.
- (IBAction)leaderboards:(id)sender
{
GKLeaderboardViewController *leaderboardViewController =
[[GKLeaderboardViewController alloc] init];
if(leaderboardViewController == nil)
{
NSLog(@"Unable to create leaderboard view controller");
return;
}
leaderboardViewController.category = @"com.dragonforged.whackacac.leaderboard";
leaderboardViewController.timeScope = GKLeaderboardTimeScopeAllTime;
leaderboardViewController.leaderboardDelegate = self;
[self presentViewController:leaderboardViewController animated:YES completion: nil];
[leaderboardViewController release];
}
For a GKLeaderboardViewController to be fully functional, a delegate method must also be provided. When this method is invoked, it is required that the view controller be dismissed as shown in this code snippet:
- (void)leaderboardViewControllerDidFinish:(GKLeaderboardViewController *) viewController
{
[self dismissModalViewControllerAnimated: YES completion: nil];
}
Note
In iOS 6 the GKLeaderboardViewController will bring up a combined view controller for challenges, leaderboards, and achievements. In iOS 4 and 5 it will present just the leaderboard view controller.
The preceding example works on iOS 4.1 and up; however, in iOS 6 a new class was provided to streamline the process of presenting leaderboards and achievements. The following example will function only on iOS 6 and newer:
- (IBAction)leaderboards:(id)sender
{
[[GKGameCenterViewController sharedController] setDelegate: self];
[[GKGameCenterViewController sharedController] setLeaderboardCategory: @"com.dragonforged.whackacac.leaderboard"];
[[GKGameCenterViewController sharedController] setLeaderboardTimeScope:GKLeaderboardTimeScopeAllTime];
[self presentViewController:[GKGameCenterViewController sharedController] animated:YES completion: nil];
}
There is also a new delegate method to be used with this view controller. It is implemented in the following fashion:
- (void)gameCenterViewControllerDidFinish:(GKGameCenterViewController *)gameCenterViewController
{
[self dismissModalViewControllerAnimated: YES completion: nil];
}
It is also possible to work with the raw leaderboard data and create customized leaderboards. For more information on this, see the later section “Going Further with Leaderboards.”
Score Challenges
iOS 6 introduced Challenges into the Game Center functionality. Challenges enable your users to dare their Game Center friends to beat their high scores or achievements. They provide a great avenue for your users to socially spread your game to their friends. All the work with Challenges are handled for you by Game Center using the new GameCenterViewController, as shown in the previous example and in Figure 3.8. However, it is also possible to create challenges in code. Calling issueChallengeToPlayers:withMessage: on a GKScore object will initiate the challenge. When a player beats a challenge, it automatically rechallenges the person who initiated the original challenge.
Figure 3.8 Challenging a friend to beat a score using iOS 6’s built-in Game Center challenges.
[(GKScore *)score issueChallengeToPlayers: (NSArray *)players message:@"Can you beat me?"];
It is also possible to retrieve an array of all pending GKChallenges for the authenticated user by implementing the following code:
[GKChallenge loadReceivedChallengesWithCompletionHandler:^(NSArray *challenges, NSError *error)
{
if (error != nil)
{
NSLog(@"An error occurred: %@", [error localizedDescription]);
}
else
{
NSLog(@"Challenges: %@", challenges);
}
}];
A challenge exists in one of four states: invalid, pending, completed, and declined.
if(challenge.state == GKChallengeStateCompleted)
NSLog(@"Challenge Completed");
Finally, it is possible to decline an incoming challenge by simply calling decline on it as shown here:
[challenge decline];
Challenges create a great opportunity to have your users do your marketing for you. If a user challenges someone who has not yet downloaded your game, that person will be prompted to buy it. Challenges are provided for you for no effort by Game Center using the default GUI, and using the earlier examples, it is fairly easy to implement your own challenge system.
Note
Whack-a-Cac does not implement code-based challenges.
Going Further with Leaderboards
The focus of this chapter has been implementing leaderboards using Apple’s standard Game Center GUI; however, it is entirely possible to implement a customized leaderboard system within your app. In this section, a brief introduction will be given to working with raw GKScore values, as well as retrieving specific information from the Game Center servers.
The following method can be added to the ICFGameCenterManager. This method accepts four different arguments. The first, category, is the leaderboard ID set in iTunes Connect for the leaderboard that this request will pertain to. This is followed by withPlayerScore:, which accepts GKLeaderboarPlayerScopeGlobal or GKLeaderboarPlayerScopeFriendsOnly. TimeScope will retrieve scores for today, this week, or all time. The last argument required is for range. Here you can specify receiving scores that match a certain range. For example,NSMakeRange(1, 50) will retrieve the top 50 scores.
- (void)retrieveScoresForCategory:(NSString *)category withPlayerScope:(GKLeaderboardPlayerScope)playerScope timeScope:(GKLeaderboardTimeScope)timeScope withRange:(NSRange)range
{
GKLeaderboard *leaderboardRequest = [[GKLeaderboard alloc] init];
leaderboardRequest.playerScope = playerScope;
leaderboardRequest.timeScope = timeScope;
leaderboardRequest.range = range;
leaderboardRequest.category = category;
[leaderboardRequest loadScoresWithCompletionHandler: ^(NSArray *scores,NSError *error)
{
[self callDelegateOnMainThread:@selector
(scoreDataUpdated:error:) withArg:scores error: error];
}];
}
There will also be a newly associated delegate callback for this request called scoreDataUpdated:error:.
-(void)scoreDataUpdated:(NSArray *)scores error:(NSError *)error
{
if(error != nil)
{
NSLog(@"An error occurred: %@", [error localizedDescription]);
}
else
{
NSLog(@"The following scores were retrieved: %@", scores);
}
}
If this example were to be introduced into Whack-a-Cac, it could look like the following:
-(void)fetchScore
{
[[ICFGameCenterManager sharedManager] retrieveScoresForCategory: @"com.dragonforged.whackacac.leaderboard" withPlayerScope:GKLeaderboardPlayerScopeGlobal timeScope:GKLeaderboardTimeScopeAllTime withRange:NSMakeRange(1, 50)];
}
The delegate method will print something similar to the following:
2012-07-29 14:38:03.874 WhackACac[14437:c07] The following scores were retrieved: (
"<GKScore: 0x83c5010>player:G:94768768 rank:1 date:2012-07-28 23:54:19 +0000 value:201 formattedValue:201 Points context:0x0"
)
Tip
To find the displayName for the GKPlayer associated with a GKScore, use [(GKPlayer *)player displayName]. Don’t forget to cache this data because it requires a network call.
Summary
Game Center leaderboards are an easy and fun way to increase the social factor of your game or app. Users love to compete and with iOS 6’s Game Center Challenge system it is easier than ever for users to share apps that they love. In this chapter you learned how to fully integrate Game Center’s leaderboards into your game or app. You should now have a strong understanding of not only Game Center authenticating but also submitting scores and error recovery. Chapter 4 will continue to expand on the capabilities of Game Center by adding social achievements to the Whack-a-Cac game.
Exercises
1. Modify Whack-a-Cac to use a custom table view for retrieving and presenting scores.
2. Modify Whack-a-Cac to use a timer-based score system instead of an integer-based system; the longer the player stays alive, the higher the score the player will earn.