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]=%@&high_score[player_name]=%@&high_score[total_score]=%@&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!