iPhone Global Leaderboard using Ruby on Rails
There are a number of public API's for storing and displaying Global High Scores or a "Leader board" in iPhone games, including CocosLive, OpenFeint, Scoreloop, Unify, AGON andZ2Live among others. We investigated the pros and cons of using these systems, but while most are free and easy to set up, they don't allow for the type of customization we needed. We wanted a system that allows for both a global leader board server, but also generating a sharable web site for each user to either send as an email link as well as view as a web app on the iPhone. Since we wanted to leverage the user data in more dynamic ways ourselves, a Rails based app made sense. I'll describe our implementation below.
The iPhone view, register the user within a settings screen. We used a modal dialog settings screen that was created using Interface Builder. The basic elements are a button to trigger a user registration session with the Rails server, and various UI elements to customize the user account including a name and user photo. Optionally, we allow the user to submit their email address and receive product information from us. And they can connect with Facebook to use their name and profile photo.
All of this information, including the photo is transmitted as an HTTP packet to the server via NSURLConnection synchronous PUT calls:
1.NSMutableURLRequest *req = [[NSMutableURLRequest alloc] initWithURL:url];
2.[req setHTTPMethod:@"PUT"];
3.[req setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"];
4.
5.// Get the image from userImageButton
6.NSData *pngImageData = UIImagePNGRepresentation([userImageButton imageForState:UIControlStateNormal]);
7.// Base64 encode the image data
8.int len = pngImageData.length;
9.char *pngImageDataBase64 = malloc(Base64encode_len(len));
10.len = Base64encode(pngImageDataBase64,pngImageData.bytes,len);
11.
12.// Get the phone UDID
13.NSString *phoneUDID = [[UIDevice currentDevice] uniqueIdentifier];
14.
15.NSString *params = [NSString stringWithFormat:@"name=%@&email=%@&send_updates=%@&image=%s&fb_uid=%@&phone=%@",UserName.text,UserEmail.text,send_updates,pngImageDataBase64,fb_uid,phoneUDID];
16.NSData *putParams = [params dataUsingEncoding:NSUTF8StringEncoding];
17.[req setHTTPBody: putParams];
18.
19.NSData *responseData = [NSURLConnection sendSynchronousRequest:req
20.returningResponse:&urlResponse
21.error:&error];
The NSURLConnection reponse codes are parsed to respond to server errors, or register a server side user ID.
The tricky part is certainly the base64 encoding of the user data, and the magic required to convert the image to a raw PNG data stream which can then be displayed on any website. We use calls to CGBitmapContextCreateImage() to create a 40x40 scaled image of the results from UIImagePickerController method. Scaling on the device was actually easier than scaling on the server, and allowed for faster response times.
There is a similar NSURLConnection synchronous PUT call to post high scores.
Next, we wanted a way to display global high scores on the iPhone as well as personal best, or something we call “highest contributors” which is list of users who have taken their high scores and allocated them to different charities. While for the light weight registration process made sense to use NSURLConnection, the server response times for calculating these rankings might be slow enough to justify an asynchronous approach. We used calls to an NSXMLParser object, controlled by threads via [NSThread detachNewThreadSelector:] methods with a callback function to display the XML data asynchronously as it’s received and parsed.
1.[NSThread detachNewThreadSelector:@selector(getUserData:) toTarget:self withObject:url];
2.
3.- (void)getUserData:(NSNotification *)notification
4.{
5. XMLReader *streamingParser = [[XMLReader alloc] init];
6. [streamingParser parseXMLFileAtURL:[NSURL URLWithString:(NSString *)notification] parseError:&parseError];
7.}
8.
9.- (void)parser:(NSXMLParser *)parser didStartElement:(NSString *)elementName namespaceURI:(NSString *)namespaceURI qualifiedName:(NSString *)qName attributes:(NSDictionary *)attributeDict
10.{
11. // An entry in the RSS feed represents an score, so create an instance of it.
12. self.currentCustomerObject = [[Customer alloc] init];
13. [self.currentCustomerObject SetCustomerType:kCustomerTypeTop50];
14. NSDictionary* params = [NSDictionary dictionaryWithObject:self.currentCustomerObject forKey:@"cust"];
15. [[NSNotificationCenter defaultCenter] postNotificationName:@"AddNewCustomer" object:self userInfo:params];
16.}
17.
18.- (void)addToCustomerList:(NSNotification *)notification
19.{
20. Customer *newCustomer = [[notification userInfo] objectForKey:@"cust"]; [self.listTop50 addObject:newCustomer];
21. [table reloadData];
22.}
23.
A thread is used to launch the getUserData: method with an URL to our Rails app XML server. This in turn launches the streaming parser which is an XMLReader object. Within this object, a parser: method creates a record for each top score record (Customer object) and a notification is sent back to the UITableView controller to add a new object to the list via the addToCustomerList: method.
Now, on the Rails server, this customer registration and high scores XML server is handled with standard CRUD techniques and standard render functions. We created a Customer controller which contains both the creation logic and the display logic.
The creation logic takes the NSURLConnection synchronous PUT call to create a new Customer record based on the parameters sent from the iPhone. The only tricky part was the encoding of the base64 for image because we used URL encoding we then have to decode this in Rails using the calls to unescape() and translate (theImage.tr(‘ ‘,’+’) which does the trick. Note that doing this allows us to render the images as embedded HTML code via <img>. We could have instead saved the images as binary data on the server but this way we have simpler rendering code, and the images can be stored in the Rails database for better portability and backup, etc.
The XML generation logic is extremely simple in Rails, this call does it all:
1. # GET /TopScores.xml
2. def TopScores
3. @customers = Customer.find(:all)
4. @d = Donation.sum(:donated_points, :group => :customer_id, :limit => 50).sort{|a,b| b[1] <=> a[1]}
5. @scores = []
6. @d.each do |c|
7. @top50 = [{:name => Customer.find(c[0]).name, :image => Customer.find(c[0]).image, :points => c[1]}]
8. @scores = @scores + @top50
9. end
10. respond_to do |format|
11. format.html # index.html.erb
12. format.xml { render :xml => @scores.to_xml(:root=>'customers', :child => 'customer') }
13. end
14. end
First we build up a list of the highest 50 scores, we parse the customer data for the total “donated_points” per customer, and then sort. We sending this list to the native Rails XML render function. The only tricky part is setting the :root and :child correctly so that the iPhone XMLReader can correctly parse the XML stream from Rails.
--yarri
Note: there is an excellent description of using theObjectiveResrource class for the iPhone in the APRESS book "iPhone Games Project" here.