Managing Local High Scores and Online Leaderboard for your iPhone Games Part 2/2

September 12, 2009

In the first part this tutorial I demonstrated how to save player scores for an iPhone game. In this post, I’ll explain how to add online support by building a high scores web service in Rails, securing the submissions, and using git to deploy (for free) onto heroku.

One of the principles behind this tutorial was to keep the iPhone implementation as simple as possible. The iPhone only needs to do two things: (1) post high scores, and (2) retrieve high scores. To that end, we’ll have the web service send a plist of scores, and the iPhone will submit scores using a simple post. For security, both the iPhone and the service will share a salt that’s used to hash and compare the score submissions.

Loading Leaderboard Scores

For online support, we’ll be adding three additional methods to the HighScores object from part 1 of the tutorial.

@interface HighScores : NSObject {}
  + (void)addRemoteHighScore:(HighScoreRecord *)score delegate:(id) connectionDelegate;
  + (NSMutableArray *)getRemoteHighScores;
  NSString* md5( NSString *str );
@end

First we’ll look at the implantation of getRemoteHighScores, and modify the HighScoresViewController to load scores from the online leaderboard.

@implementation HighScores

. . .

NSString * const apiSalt = @"999888999";
NSString * postString = @"high_score[auth_token]=%@&high_score[player_name]=%@&high_score[total_score]=%@&high_score[iphone_udid]=%@";

. . .

+ (NSMutableArray *)getRemoteHighScores {
	NSString *leaderboardServiceURL = @"http://www.example.com/high_scores.xml"

	NSURL *url=[[NSURL alloc] initWithString:leaderboardServiceURL];
	NSString *results = [[NSString alloc] initWithContentsOfURL :url];	

	NSString *error;
	NSData* plistData = [results dataUsingEncoding:NSUTF8StringEncoding];
	NSPropertyListFormat format;
	NSArray* plist = [NSPropertyListSerialization propertyListFromData:plistData mutabilityOption:kCFPropertyListMutableContainersAndLeaves format:&format errorDescription:&error];

	NSMutableArray *highScores = [[[NSMutableArray alloc] init] autorelease];

	NSMutableDictionary *dict;

	if(plist){
		for(dict in plist) {
			NSLog(@"dict: %@", dict);

			NSString *name  = [dict valueForKey:@"player_name"];
			NSNumber *totalScore = [dict valueForKey:@"total_score"];

			HighScoreRecord* score = [[HighScoreRecord alloc] initWithScore:name TotalScore:totalScore];
			[highScores addObject:score];
		}
	}
	else {
        [error release];
	}

	[url release];

	return highScores;
}

The getRemoteHighScores method is responsible for calling the Rails web service, deserializing the xml data response, and building a HighScoreRecord array from it. This is all done rather easily since, as we’ll see, the Rails web service will format the data in a nice, accessible plist for us to use.

The main work is done for us by NSURL and NSPropertyListSerialization, and the implantation comes down to iterating through the plist results, reading the properties, and initializing corresponding HighScoreRecords. Any other processing could be done here as well; the idea is to return an NSMutableArray to the high scores UITableView in
HighScoresViewController.

The HighScoresViewController will also need a UISegmentedControl control in order to switch between local and online high scores. Here’s the updated header file.

#import <UIKit/UIKit.h>

#import "HighScoreCell.h"
#import "HighScoreRecord.h"
#import "HighScores.h"

@interface HighScoresViewController : UIViewController <UITableViewDelegate, UITableViewDataSource> {
	IBOutlet UITableView *highScoreTable;
	IBOutlet UISegmentedControl *scoreTableSelector;
	NSArray *highScoreData;

}
@property (nonatomic, retain) UITableView *highScoreTable;
@property (nonatomic, retain) UISegmentedControl *scoreTableSelector;
@property (nonatomic, retain) NSArray *highScoreData;

- (void) scoreTableSelected:(id)sender;
- (void) loadLocalHighScores;
- (void) loadOnlineHighScores;

@end

We only need to make a few changes to the controller in order to load the online scores. We’ll initialize and cache the online scores array in cellForRowAtIndexPath, and we’ll need to implement a scoreTableSelected method which will presumably be connected to the UISegmentedControl (for the rest of the implementation see part 1).

@implementation HighScoresViewController

@synthesize scoreTableSelector;

. . . 

- (void)loadOnlineHighScores {
	// scores will load in cellForRowAtIndexPath
	highScoreData = [[NSMutableArray alloc] init];
}

- (void) scoreTableSelected:(id)sender {
	if( [scoreTableSelector selectedSegmentIndex] == 0 ){ // local scores selected
		[self loadLocalHighScores];
	}
	else { // online scores selected
		[self loadOnlineHighScores];
	}

	[highScoreTable reloadData];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
	if (highScoreData.count < 1){
		highScoreData = [[HighScores getRemoteHighScores] retain];
	}

        . . .
}
@end

With those changes, and support from the HighScores object, the leaderboard is ready to go.

Submitting Leaderboard Scores

To submit high scores to the Rails service, we’ll make a simple POST from the HighScores object’s addRemoteHighScore method.

Also take note of the two strings, apiSalt and postString. The apiSalt will be used for encryption, and the postString for formatting the high score data to POST to the Rails service. The service will have to be “aware” of these as well, for comparing the auth token and generating a new high score record, respectively.

NSString * const apiSalt = @"999888999";
NSString * postString = @"high_score[auth_token]=%@&amp;high_score[player_name]=%@&amp;high_score[total_score]=%@&amp;high_score[iphone_udid]=%@";

Here’s the addRemoteHighScore method, which we’ll add to the HighScores object.

+ (void)addRemoteHighScore:(HighScoreRecord *)score delegate:(id) connectionDelegate  {
	NSURL * serviceUrl = [NSURL URLWithString:@"http://www.example.com/high_scores"];

	NSString *udid = [UIDevice currentDevice].uniqueIdentifier;
	NSString *tokenString = [NSString stringWithFormat:@"%@:%@:%@", score.totalScore, apiSalt, udid];

	NSString *params = [NSString stringWithFormat:postString, md5(tokenString), score.name, score.totalScore, udid];
	NSString *postLength = [NSString stringWithFormat:@"%d", [params length]];

	NSMutableURLRequest * serviceRequest = [NSMutableURLRequest requestWithURL:serviceUrl];
	[serviceRequest setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"];
	[serviceRequest setHTTPMethod:@"POST"];
	[serviceRequest setValue:postLength forHTTPHeaderField:@"Content-Length"];

	[serviceRequest setHTTPBody:[params dataUsingEncoding:NSUTF8StringEncoding]];

	[[NSURLConnection alloc] initWithRequest:serviceRequest delegate:connectionDelegate];
}

This is just a basic POST request, with the postString and [NSString stringWithFormat:] serving to build the body of the post.

In order to call NSURLConnection, we’ll need a connection delegate. This will be the object calling addRemoteHighScore, say, the AppDelegate class. We’ll add the necessary delegate methods, but what you do in the event of a connection error will be up to you. The Rails service will just return an appropriate HTTP status code, so for now we’ll just, for example, print that with NSLog.

#import "MyLeaderboardAppDelegate.h"

@implementation MyLeaderboardAppDelegate

. . .

#pragma mark -
#pragma mark NSURLConnection Delegate Methods
#pragma mark -

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    [connection release];

    // inform the user (or don't; whatever)
    . . .
}

- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
	NSLog(@"Response Code: %d",[response statusCode]);
}

[sourcecode language='c']
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    [connection release];
}

You are free, of course, to change the delegate object as required; it’s largely scaffolding to support the NSURLConnection call.

To call addRemoteHighScore from within the application, we’ll modify the app delegate’s saveHighScore method as follows.

- (void)saveHighScore {
        . . .
	[HighScores addRemoteHighScore:highScore delegate:self];
}

Securing the Scores

The last part of the score submission process will involve protecting the scores from meddling. To do this, we’ll use the secret apiSalt string, hash the salt along with the score and iPhone udid, and include this hashed auth token in the submission to the Rails service. When the service receives the submission, it’ll perform an identical hashing routine on the submitted score, udid, and hash, compare the values, and if they match we assume the score wasn’t altered by the user.

The format we’ll use will be: SCORE:SALT:UDID

Take a look at the two relevant lines in the addRemoteHighScore method.

NSString *tokenString = [NSString stringWithFormat:@"%@:%@:%@", score.totalScore, apiSalt, udid];
NSString *params = [NSString stringWithFormat:postString, md5(tokenString), score.name, score.totalScore, udid];

Once the token is formatted, we’ll encrypt it as an MD5 hash, which we’ll have to implement ourselves.

Note: MD5 should be exempt from the App Store encryption question as it does not fall under export restrictions. So using this method, you can still answer no to the encryption question.

NSString* md5( NSString *str )
{
	const char *cStr = [str UTF8String];
	unsigned char result[CC_MD5_DIGEST_LENGTH];
	CC_MD5( cStr, strlen(cStr), result );
	return [NSString stringWithFormat:
			@"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
			result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],
			result[8], result[9], result[10], result[11], result[12], result[13], result[14], result[15]
	];
}

Caveat: there are actually still a few weakness is this design. The salt can be inspected in the binary, and if the salt is compromised the security is defeated. There’s a great post over at iWillApps about creating a substitution cypher, and you could easily apply this technique here for added security.

The Rails Leaderboard Web Service

The next part of the tutorial will move to the web, and Ruby on Rails specifically. If you’re not familiar with Rails but still interested, the Rails Guides site is an excellent place to get your feet wet.

We’ll also be using git, a distributed version control system. This will let us deploy to heroku, a free Rails host. In fact, they have a quick introductory guide to using git, and links to additional git resources.

Once you have Rails and git set up, you’ll need to install the plist gem to your Rails project.

$ sudo gem install plist

Now we’re prepared to program the service.

Project Configuration

We’ll need to take a few actions to configure the service. First, we need to create a database schema to store our scores.

config/create_score_schema.rb

class CreateCoreSchema < ActiveRecord::Migration
  def self.up
    create_table :high_scores, :force => true do |t|
      t.string :player_name, :limit => 32, :null => false
      t.string :iphone_udid, :limit => 64, :null => false
      t.integer :total_score, :null => false

      t.timestamps
    end
  end

  def self.down
    drop_table :high_scores
  end
end

config/routes.rb

ActionController::Routing::Routes.draw do |map|
  map.resources :high_scores, :only => [:index, :create]
end

config/environment.rb

RAILS_GEM_VERSION = '2.3.3' unless defined? RAILS_GEM_VERSION
require File.join(File.dirname(__FILE__), 'boot')

Rails::Initializer.run do |config|
  config.gem 'plist'
  config.time_zone = 'UTC'

  module LeaderboardAuth
    SECRET_SALT = '999888999'
  end
end

Standard Rails configuration stuff so far, except note that we’re configing the plist gem here, and declaring a module. The purpose of LeaderboardAuth is just to reference a salt value, the same one used by the iPhone to hash the high score submission. It’s declared here in environment.rb for convenience and we’ll use it throughout the service and tests.

Leaderboard Web Service

Now that the configuration is set, we can start writing the service proper. We’ll only need a controller class, and a model class, since we won’t be serving any HTML, just plist XML lists of high scores.

First, our only model, HighScore.

app/models/high_score.rb

require 'digest'
require 'plist'

class HighScore < ActiveRecord::Base
    attr_accessor :auth_token

    validates_presence_of :player_name, :iphone_udid, :total_score, :auth_token
    validates_length_of :player_name, :within => 1..32, :allow_nil => true

    named_scope :sorted_by_score, :order => 'total_score DESC', :limit => 10

   def validate
      return if auth_token.blank?
      auth = Digest::MD5.hexdigest( [total_score, LeaderboardAuth::SECRET_SALT, iphone_udid].join(':') )

      errors.add(:auth_token, "unauthorized high score submission") if auth_token.downcase != auth
   end

   def to_plist_node
     return {:player_name => self.player_name, :total_score=>self.total_score, :created_at => self.created_at}.to_plist(false)
    end
end

All pretty standard for a Rails model, and I’ll summarize: we need to require digest and plist so we can perform a MD5 hash, and serialize the model into plist format.

There isn’t an “auth_token” field in the database, but the model needs one to store the hashed token when the high score POST is submitted, so we add it as an attr_accessor.

The model will perform some basic validation, with the security test handled in the validate method. An MD5 hash is created from the model’s score, udid, and the application’s secret salt value. The format is the same as used on the iPhone, SCORE:SALT:UDID. Note, if you were running additional substitution routines you’ll have to reproduce them here as well while reconstructing the hash.

auth = Digest::MD5.hexdigest( [total_score, LeaderboardAuth::SECRET_SALT, iphone_udid].join(':') )

If the two hashes don’t match, we can assume that the submission has been altered.

errors.add(:auth_token, "unauthorized high score submission") if auth_token.downcase != auth

To serialize the HighScore model’s properties into plist format, we implement a to_plist_node method that the plist gem will use to format the output.

return {:player_name => self.player_name, :total_score=>self.total_score, :created_at => self.created_at}.to_plist(false)

The controller will only need to respond to GET and POST requests, to retrieve and submit high scores. It follows basic Rails conventions, and the real work is delegated to configuration settings and the high scores model.

app/models/high_scores_controller.rb

class HighScoresController < ApplicationController
    include ApplicationHelper

    skip_before_filter :verify_authenticity_token

    def index
      @scores = HighScore.sorted_by_score.all

      respond_to do |wants|
        wants.xml { render :xml => @scores.to_plist, :status => :ok }
        wants.any { render :nothing => true, :status => :ok }
      end
    end

    def create
      @highscore = HighScore.new(params[:high_score])

      header_status = @highscore.save ? :ok : :unprocessable_entity

      respond_to do |wants|
        wants.xml { render :nothing => true, :status => header_status }
        wants.any { render :nothing => true, :status => header_status }
      end
    end
end

And that’s the Rails online high scores service! To complete the project, we’ll add a few basic tests. I wouldn’t review them, but they should give us confidence to expand on the core leaderboard features without fear of breaking the service.

Testing the Service

We’ll need three files. A unit test, a functional test, and a fixture with a few mock scores.

test/fixtures/high_scores.xml

player1:
    player_name: player one
    total_score: 10
    iphone_udid: 2b6f0cc904d137be2e1730235f5664094b831186

player2:
    player_name: player two
    total_score: 15
    iphone_udid: 2b6f0ce614c43cbb2e0730235f5664094b83b487

player3:
    player_name: player three
    total_score: 20
    iphone_udid: 2bef0b11cff51c9c080730235f566401e80bfe1

test/unit/high_score_test.rb

require File.join(File.dirname(__FILE__), '/../test_helper')
require 'digest'

class HighScoreTest < ActiveRecord::TestCase
  fixtures :all

  def test_should_validate_auth_hash
    salt = LeaderboardAuth::SECRET_SALT
    name = 'player X'
    udid = "2bef0b11cff51cbb2e0730235f5664094b83b487"

    auth_token = Digest::MD5.hexdigest( [name,salt,udid].join(':') )

    score = HighScore.new( :player_name => name,
                          :iphone_udid => udid,
                          :total_score => 50,
                          :auth_token => auth_token)

    assert score.save, "Save failed: #{score.errors.full_messages}"

  end

  def test_should_not_validate_with_bad_auth_hash
    salt = "xxxxxxxxxxxx"
    name = 'player X'
    udid = "2bef0b11cff51cbb2e0730235f5664094b83b487"

    auth_token = Digest::MD5.hexdigest( [name,salt,udid].join(':') )

    score = HighScore.new( :player_name => name,
                          :iphone_udid => udid,
                          :total_score => 50,
                          :auth_token => auth_token)

    assert !score.save
    assert score.errors.on(:auth_token)
  end
end

test/functional/high_scores_controller_test.rb

require File.join(File.dirname(__FILE__), '/../test_helper')

class HighScoresControllerTest < ActionController::TestCase
  def setup
    @plist_expression = /<!DOCTYPE plist PUBLIC "-\/\/Apple Computer\/\/DTD PLIST 1\.0\/\/EN" "http:\/\/www\.apple\.com\/DTDs\/PropertyList-1\.0\.dtd">/
  end

  def test_should_get_index
    get :index, { :format => "xml", :limit => 10 }

    assert_response :success
    assert_not_nil assigns(:scores)
    assert_match @plist_expression, @response.body

    plist = Plist::parse_xml(@response.body)
    assert_equal "Array", plist.class.to_s
    assert_operator plist.size, ">", 0
  end

  def test_should_post_to_create
    salt = LeaderboardAuth::SECRET_SALT
    name = 'player X'
    udid = "2bef0b11cff51cbb2e0730235f5664094b83b487"

    auth_token = Digest::MD5.hexdigest( [name,salt,udid].join(':') )

    assert_difference('HighScore.count') do
      post :create, { :format => "xml", :high_score => {
                                          :player_name => name,
                                          :iphone_udid => udid,
                                          :total_score => 50,
                                          :auth_token => auth_token} }
    end

    assert_response :success
  end
end

Hosting the Service on Heroku

heroku is an excellent Rails host with fancy cloud architecture and a tiered pricing plan that starts at free. The best part about heroku, though, is how easy they’ve made Rails deployments. They did this by creating, essentially, a deployment gem that gives you some slick command line powers. Here’s how you deploy your app for the first time (ripped from the heroku front page) using the heroku gem, and of course, git.

$ sudo gem install heroku
$ heroku create myapp
$ git push heroku
$ heroku domains:add example.com
$ heroku rake db:migrate
$ heroku bundles:capture

You’ll get a basic subdomain at heroku.com which you’ll call from the iPhone. If your game takes off and traffic rises, heroku will scale seamlessly for you, and you’ll generally be well taken care of by a responsive support staff who know a great deal about the environment they host. Head over to their site for more information. (I’m in no way affiliated with them, just a happy user and impressed developer).


With the service built, and the iPhone posting and retrieving high scores, we’ve finally reached the end of the tutorial.

Good luck!

Tags: , , ,

5 Responses

  1. [...] LATEST PROJECT: Managing Local High Scores and Online Leaderboard for your iPhone Games Part 2/2 [...]

  2. Just posted my method:
    http://www.playngive.com/Blog/Entries/2009/9/14_iPhone_Global_Leader_Board_Server_in_Rails.html

    Marketing folks don’t understang code formatting… my post is a little hard to read, sorry.

    –yarri

  3. really good tutorial, thanks.

    but what should
    CC_MD5_DIGEST_LENGTH
    and
    CC_MD5
    be?

    (have i missed something?..)

  4. Fantastic. Thanks for writing this up.

    What kind of request volumes has your Rails API handled? I’m building something similar deployed on Heroku and I’m curious to know what it can handle.

  5. The tests fail because the auth_token you generate in the tests use player_name, but in the validate you use the score for the temp auth_token

Leave a Reply

Powered by WP Hashcash