Saturday, July 12, 2014

Rejected from the Apple Store for using default Google Play Game Services sign in implementation

So... yeah. The title says it all. I'm frustrated and on the verge of giving up, so forgive me if my tone is off on this one.

Several weeks ago, I submitted my latest game, made in Cocos2D-x, to Apple for approval. I was rejected with the following message, citing Apple 10.6 (dealing with UI):

"Your app takes the user out to Safari when they opt-in to signing in with Google"

I explained the situation: I didn't build that part, it's build in to the GPGS code. All I call is [[GPPSignIn sharedInstance] authenticate]; . Google even says in the documentation, for iOS they try to use the Google Plus app if it's available, then try Safari. There's no way to do it in-app as provided by Google.

I was still rejected, and lost my appeal, even though I've seen and used several apps that use the same implementation. I've also seen the same style used for GREE logins (i.e., the official 1 vs 100 app). It's not an unknown thing, and it's not new. Yes, I'm aware that's not a reason to approve it. But as it stands Apple is (or "should be") rejecting ALL apps that use Google Play Games Services. Unless there's some way of signing in in without leaving the app that I simply can't get working. I'd think something that large would warrant a message and/or a fix from Apple or Google addressing the situation.

After doing a ton of digging and only coming up with a handful of people who've had similar issues (and no solutions), I decided that perhaps my issue was the reviewer and not the actual policy. Again, rejected, same thing: though it did reference my previous rejection, so if it's related to that I'm not sure.

Then, just this week Google announced an update to Google Play Game Services, and I was hopeful that there might be a solution in there. Especially after this post came out about a Cocos2D-X implementation being available. Unfortunately, using the code from the sample the relevant call GPGSManager::BeginUserInitiatedSignIn(); still opens up Safari.

So, I'm done. I've spent weeks researching and submitting and getting rejected in the name of using a unified system for achievements and leaderboards. If someone has advice or a tip, feel free to chime in. But at this point I'm probably giving up and implementing Game Center for iOS and keeping GPGS only for Android.

Saturday, April 19, 2014

Integrating Google Play Game Services into Cocos2D-X

I’m making a few mobile games, and finally decided it was time to move to a cross-platform engine. One piece that seemed daunting, and didn’t have a TON of documentation, was integrating a Cocos2D-X game with the Google Play Game Services APIs. GPGS has some great features, like cloud saves, achievements, and leaderboards, and I wanted those.

This is by no means an “expert” opinion, and I fully acknowledge that I use hacky/subpar code in places. Feel free to disagree with my design decisions, etc. This is less a “this is how it’s done” and more of a “this worked for me” article. Coder beware, and all that.

I used Cocos2D-X 2.2.3, the current non-beta version. I have no idea what might be different if you use 3.x, or any other version.

I also borrowed some from the Avalon game center example (link below the post). I kept the “Game Center” name, but little of the code beyond the concept and some JNI helpers.

I didn’t do cloud saves for this one, only Achievements and Leaderboards. If I do the cloud saves later, I’ll post an update.

I initially started looking at the C++ library available for GPGS, and decided that was a mistake. While it certainly CAN be done that way, you lose some of the handier integrations with iOS and Android for things like launching the popups/activites for viewing achievements and leaderboards. The solution I used, at a high level, looks like this:

  • A header file (GameCenter.h) that both Android and iOS see.
  • A .mm file (GameCenter.mm) stored outside the main Classes folder structure, so I can make it so only Xcode can see it.
  • A .cpp file (GameCenter.cpp) stored in the Classes folder structure, but only included in Eclipse.

...you can see where this is going, I hope. Both the .mm and the .cpp file implement the header contract. The .mm file points at the obj-C functionality, while the .cpp file uses JNI to pass code to GameCenter.java static methods.

While I know it’s complex, I’m going to skip over the initial setup of GPGS. Follow the guidelines Google publishes for adding GPGS to your iOS and Android projects. A couple tips when using Cocos2D-X:

  • Grab the BaseGameActivity (and the related classes and helpers) from the Android sample. Set your main activity to inherit from it, and change BaseGameActivity to inherit from Cocos2dxActivity. I know it’s not a FragmentActivity... that doesn’t seem to cause issues.
  • Use the RootViewController in Xcode. For me, the viewDidLoad stuff wouldn’t fire when launching a Cocos2D-X project, so I put my initial launch code in initWithNibName.
  • I used a static reference to my Activity to launch the GPGS methods from my static java Game Center. Yes, I know this is leaky and bad.

Without any further ado, the code:

GameCenter.h
 #ifndef __XXXXX__GameCenter__  
 #define __XXXXX__GameCenter__  
 class GameCenter  
 {  
 public:  
 void showAchievements();  
 void unlockAchievement(const char* achievementId);  
 void showLeaderboard(const char* leaderboardId);  
 void postToLeaderboard(const char* leaderboardId, int score);  
 bool isSignedIn();  
 void signIn();  
 };  
 #endif /* defined(__XXXXX__GameCenter__) */  

GameCenter.mm
 #include "GameCenter.h"   
 #include "RootViewController.h"   
 bool GameCenter::isSignedIn()   
 {   
 return [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] isSignedIn];   
 }   
 void GameCenter::signIn()   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] startGoogleGamesSignIn];   
 }   
 void GameCenter::showAchievements()   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] showAchievements];   
 }   
 void GameCenter::unlockAchievement(const char* achievementId)   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] unlockAchievement: [NSString stringWithUTF8String:achievementId]];   
 }   
 void GameCenter::showLeaderboard(const char* leaderboardId)   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] showLeaderboard: [NSString stringWithUTF8String:leaderboardId]];   
 }   
 void GameCenter::postToLeaderboard(const char* leaderboardId, int score)   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] postToLeaderboard: [NSString stringWithUTF8String:leaderboardId] withScore:score];   
 }  

GameCenter.cpp
 #include "GameCenter.h"  
 #include "cocos2d.h"  
 #include "platform/android/jni/JniHelper.h"  
 #include <jni.h>  
 namespace helper {  
 namespace gamecenter {  
 const char* const CLASS_NAME = "com/bloodhoundstudios/gridinfect/GameCenter";  
 void callStaticVoidMethod(const char* name)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "()V")) {  
     t.env->CallStaticVoidMethod(t.classID, t.methodID);  
     t.env->DeleteLocalRef(t.classID);  
   }  
 }  
 bool callStaticBoolMethod(const char* name)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "()Z")) {  
     bool result = (t.env->CallStaticBooleanMethod(t.classID, t.methodID) == JNI_TRUE);  
     t.env->DeleteLocalRef(t.classID);  
     return result;  
   } else {  
     return false;  
   }  
 }  
 void callStaticVoidMethodWithString(const char* name, const char* idName)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "(Ljava/lang/String;)V")) {  
     jstring jIdName = t.env->NewStringUTF(idName);  
     t.env->CallStaticVoidMethod(t.classID, t.methodID, jIdName);  
     t.env->DeleteLocalRef(t.classID);  
     t.env->DeleteLocalRef(jIdName);  
   }  
 }  
 void callStaticVoidMethodWithStringAndInt(const char* name, const char* idName, const int score)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "(Ljava/lang/String;I)V")) {  
     jstring jIdName = t.env->NewStringUTF(idName);  
     t.env->CallStaticVoidMethod(t.classID, t.methodID, jIdName, (jint)score);  
     t.env->DeleteLocalRef(t.classID);  
     t.env->DeleteLocalRef(jIdName);  
   }  
 }  
 } // namespace gamecenter  
 } // namespace helper  
 bool GameCenter::isSignedIn()  
 {  
 try  
 {  
 return helper::gamecenter::callStaticBoolMethod("isSignedIn");  
 }  
 catch (std::exception& e)  
 {  
 }  
 return false;  
 }  
 void GameCenter::signIn()  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethod("signIn");  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::showAchievements()  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethod("showAchievements");  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::unlockAchievement(const char* achievementId)  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethodWithString("unlockAchievement", achievementId);  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::showLeaderboard(const char* leaderboardId)  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethodWithString("showLeaderboard", leaderboardId);  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::postToLeaderboard(const char* leaderboardId, int score)  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethodWithStringAndInt("postToLeaderboard", leaderboardId, score);  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  

RootViewController.mm (the relevant parts)
 #import "RootViewController.h"   
 #import <GooglePlayGames/GooglePlayGames.h>   
 @implementation RootViewController   
 static NSString * const kDeclinedGooglePreviously = @"UserDidDeclineGoogleSignIn";   
 static NSInteger const kErrorCodeFromUserDecliningSignIn = -1;   
 - (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {   
 if ((self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil])) {   
 // Custom initialization   
 GPPSignIn *signIn = [GPPSignIn sharedInstance];   
 signIn.clientID = kClientID;   
 signIn.scopes = [NSArray arrayWithObjects:   
 @"https://www.googleapis.com/auth/games",   
 @"https://www.googleapis.com/auth/appstate",   
 nil];   
 signIn.language = [[NSLocale preferredLanguages] objectAtIndex:0];   
 signIn.delegate = self;   
 signIn.shouldFetchGoogleUserID =YES;   
 self.currentlySigningIn = [[GPPSignIn sharedInstance] trySilentAuthentication];   
 }   
 return self;   
 }   
 (...)   
 -(void)finishedWithAuth:(GTMOAuth2Authentication *)auth error:(NSError *)error   
 {   
 self.currentlySigningIn = NO;   
 if (error.code == 0 && auth) {   
 NSLog(@"Success signing in to Google! Auth is %@", auth);   
 [self startGoogleGamesSignIn];   
 } else {   
 NSLog(@"Failed to log into Google\n\tError=%@\n\tAuthObj=%@", [error localizedDescription],   
 auth);   
 if ([error code] == kErrorCodeFromUserDecliningSignIn) {   
 [[NSUserDefaults standardUserDefaults] setBool:YES forKey:kDeclinedGooglePreviously];   
 [[NSUserDefaults standardUserDefaults] synchronize];   
 }   
 }   
 }   
 -(bool)isSignedIn   
 {   
 return self.currentlySigningIn || [[GPGManager sharedInstance] isSignedIn];   
 }   
 -(void)startGoogleGamesSignIn   
 {   
 if ([[GPGManager sharedInstance] isSignedIn]) {   
 [[GPGManager sharedInstance] signOut];   
 } else {   
 [[GPGManager sharedInstance] signIn:[GPPSignIn sharedInstance]   
 reauthorizeHandler:^(BOOL requiresKeychainWipe, NSError *error)   
 {   
 if (requiresKeychainWipe)   
 {   
 [[GPPSignIn sharedInstance] signOut];   
 }   
 [[GPPSignIn sharedInstance] authenticate];   
 }];   
 }   
 }   
 - (void)showAchievements {   
 GPGAchievementController *achController = [[GPGAchievementController alloc] init];   
 achController.achievementDelegate = self;   
 [self presentViewController:achController animated:YES completion:nil];   
 }   
 - (void)achievementViewControllerDidFinish:(GPGAchievementController *)viewController {   
 [self dismissModalViewControllerAnimated:YES];   
 }   
 - (void)unlockAchievement:(NSString*)achievementId {   
 GPGAchievement *unlockMe = [GPGAchievement achievementWithId:achievementId];   
 [unlockMe unlockAchievementWithCompletionHandler:^(BOOL newlyUnlocked, NSError *error)   
 {   
 if (error)   
 {   
 }   
 else if (!newlyUnlocked)   
 {   
 }   
 else   
 {   
 NSLog(@"Hooray! Achievement unlocked!");   
 }   
 }];   
 }   
 - (void)showLeaderboard:(NSString*)leaderboardId {   
 GPGLeaderboardController *leadController = [[GPGLeaderboardController alloc] initWithLeaderboardId:leaderboardId];   
 leadController.leaderboardDelegate = self;   
 [self presentViewController:leadController animated:YES completion:nil];   
 }   
 - (void)leaderboardViewControllerDidFinish:(GPGLeaderboardController *)viewController {   
 [self dismissModalViewControllerAnimated:YES];   
 }   
 - (void)postToLeaderboard:(NSString*)leaderboardId withScore:(int)score {   
 GPGScore *myScore = [[GPGScore alloc] initWithLeaderboardId:leaderboardId];   
 myScore.value = score;   
 [myScore submitScoreWithCompletionHandler: ^(GPGScoreReport *report, NSError *error)   
 {   
 if (error)   
 {   
 }   
 else   
 {   
 }   
 }];   
 }   
 @end  

GameCenter.java (the toast doesn't work, I just hadn't pulled it yet)
 import android.widget.Toast;  
 import com.google.android.gms.games.Games;  
 public class GameCenter {  
   static void signIn() {  
    try {  
    if (XXXXX.Instance != null) {  
     if (!XXXXX.Instance.isSignedIn()) {  
     XXXXX.Instance.beginUserInitiatedSignIn();  
     } else {  
     XXXXX.Instance.signOut();  
     Toast.makeText(XXXXX.Instance, "Signed out", Toast.LENGTH_SHORT).show();  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
  static void showAchievements() {  
  try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     XXXXX.Instance.startActivityForResult(  
      Games.Achievements.getAchievementsIntent(  
       XXXXX.Instance.getApiClient()),   
       41267);  
     }  
    }  
    } catch (Exception ex) {  
    }  
  }  
   static void unlockAchievement(String achievementId) {  
    try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     Games.Achievements.unlock(XXXXX.Instance.getApiClient(), achievementId);  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
   static void showLeaderboard(String leaderboardId) {  
  try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     XXXXX.Instance.startActivityForResult(  
      Games.Leaderboards.getLeaderboardIntent(  
       XXXXX.Instance.getApiClient(), leaderboardId),  
       76789);  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
   static void postToLeaderboard(String leaderboardId, int score) {  
  try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     Games.Leaderboards.submitScore(XXXXX.Instance.getApiClient(), leaderboardId, score);  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
 }  

That should be enough to get you going! Happy Coding!
Google Play Game Services
Cocos2D-X
Avalon (GitHub)

Sunday, March 30, 2014

Cocos2D-X Android UnsatisfiedLinkError: Couldn't load cocos2dcpp

So this stumped me for several hours, and in an effort to help others who might have the same issue, I'm posting this here. Hopefully someone finds this in their frantic Google searching.

I always use the Intel emulators, because they are much faster (especially with HAXM installed!). When developing with the Android NDK and Cocos2D-X, however, that's apparently an issue.

So, if you're stuck with that linker error, try making an emulator using ARM and see if that works. Solved it for me.

Happy coding!

Thursday, August 29, 2013

Quick Notes on Android RSS

This is a quick and dirty post for a very small user base, but I figured it's been a while so I thought I'd throw up something new.

I am building a podcast app in android, and I'm using Android Annotations (AA) for the feed reading. AA uses Spring Android for the rest (de)serialization, which uses the Android ROME Feed Reader for the various rss formats, which is what I need for my podcasts. Everyone follow that chain of dependencies? Good. Don't forget JDom at the end or ROME blows up. ;-)

So AA allows me to set the converter for reading the xml like so:
@Rest(rootUrl = "", converters = { SyndFeedHttpMessageConverter.class,  })
The problem here is SyndFeedHttpMessageConverter only supports application/atom+xml and application/rss+xml out of the box. And anyone who's ever tried to consume web code knows that everyone and their mother has a content type to use because the format is pretty open. So you wind up with rss or atom sites with contentTypes of application/xml or text/xml, which will fail.

AA doesn't let you set the accepted media types, however, at least not as of version 2.7.1 - so you are forced to leave them behind, which is what I did here. All you have to do is copy out the AA generated code and make your changes, but it's not easy to figure out that that's an actual shortcoming of AA, hence this post. Here's my new shiny code, with the changes in bold:
public class PodcastReaderProxy2
{
    private RestTemplate restTemplate;
    private String rootUrl;
    public PodcastReaderProxy2() {
        restTemplate = new RestTemplate();
        SyndFeedHttpMessageConverter converter = new SyndFeedHttpMessageConverter();
        List<MediaType> supportedTypes = new ArrayList<MediaType>();
supportedTypes.addAll(converter.getSupportedMediaTypes());
supportedTypes.add(MediaType.APPLICATION_XML);
supportedTypes.add(MediaType.TEXT_XML);
converter.setSupportedMediaTypes(supportedTypes);
restTemplate.getMessageConverters().add(converter);
        rootUrl = "";
    } 
    public void setRootUrl(String rootUrl) {
        this.rootUrl = rootUrl;
    } 
    public SyndFeed getFeed() {
        HttpHeaders httpHeaders = new HttpHeaders();
        HttpEntity<Object> requestEntity = new HttpEntity<Object>(httpHeaders);
        return restTemplate.exchange(rootUrl.concat(""), HttpMethod.GET, requestEntity, SyndFeed.class).getBody();
    }
}
 Hopefully this post saves someone else doing REST with AA some time: while my example is focused on podcasts, it's definitely helpful to anyone looking to add some additional supported media types to their web calls.

Some Links:
https://github.com/excilys/androidannotations/wiki/Rest-API#rest
http://www.springsource.org/spring-android
http://static.springsource.org/spring-android/docs/1.0.x/reference/htmlsingle/

Happy Coding!

Thursday, April 11, 2013

Extending Dobjanschi - An Attempt at an Android REST Client Pattern


I've been working on several mobile apps, most of them needing some way to communicate with services of the REST variety. This is an incredibly useful talk from Google I/O 2010 by Virgil Dobjanschi - if you make RESTful clients and haven't seen it, I recommend you find an hour to watch it and then find considerably longer to digest it.

Google I/O 2010 video and presentation materials here.

The pattern that really jumped at me was B: the idea of sending everything through the Content Provider. So I started making a 'simple' POC that quickly grew out of control. Without going into the development details TOO much at this point, I was stuck on two points:

  • I didn't like the idea that my CRUD calls to the Content Provider were either hitting a service, or hitting a local database. I wanted to distinguish them.
  • When Dobjanschi made his talk, Loaders didn't exist yet.

In the end, I created a pattern that was something of an amalgam of A and B: I've attempted to draw it here.

To explain in words:

  • Your activity/fragment inits a Cursor Loader and assigns something (probably itself) as a callback.
  • Your activity/fragment creates an adapter - probably ideally something that extends CursorAdapter - and assigns it to your listviews, etc.
  • Any time your activity/fragment needs to DO something (even w/o user interaction, like making a refresh request on load) it calls into your Content Manager.
  • Content Manager crafts your intent and fires it off to your Service Helper.
  • Service Helper, a singleton, tracks existing calls. If your call is a 'new' one not already running, it fires it off to the Service - which uses Intent Filters.
  • The Service (which maybe or maybe not has a queue of requests and processes them one at a time) makes your REST params, spins up a new thread, and passes them to the REST Method.
  • The REST Method handles the HTTP request/response calls, and sends the raw data to the Processor.
  • The Processor munges response data into some Content Provider-centric logic (either DTOs, or if you prefer straight to ContentValues) and makes the CRUD calls into the CP to make your database reflect the results.
  • After finishing, the Processor notifies the Content Resolver of new data. This can also be done pre- or post- HTTP request/response, if you want to show the loading process to the user. (In which case, the REST Method will need to call the Processor during each step.)
  • Because you already registered your UI components, the rest is 'magic': your adapter will find the new data and refresh the UI automatically.
This assumes a SQLite database behind your Content Provider - I put another layer between my CP and my DB and called it "__DataSource" - my CP handles the URI routing to different DataSource methods while DataSource handles the actual heavy lifting (making queries, parsing ContentValues, etc). I've also found it handy for my DTOs to have 'fromCursor' methods on them - makes it very easy for my adapter to do something like "view.bind(Data.fromCursor(cursor));" in bindView.

Hopefully this gives some good direction on how I've gone about implementing a responsive client. I feel the pattern is good enough that it works beyond REST - any long-running async calls would likewise benefit from something like this, and it keeps you from having to constantly know WHEN to update the front end. You just bind it, and walk away: whenever the data changes, you sound off to the Content Resolver and any active pieces that care come running.

Up next (soon I hope) I'll discuss using an in-memory-only "database" for your Content Provider repo.

Happy coding!

Wednesday, October 3, 2012

Wanted: 2D artist/animator for mobile game(s)

EDIT: I've selected an artist, thanks to all the applicants!

I am looking for an artist and animator to work with me on my current project, a mobile app game for Android (and eventually iPhone). The project is basically feature complete, I have been working on it for several months now, and what is left is polishing and adding a few animations I haven't made placeholders for. The art right now is all just temp stuff that I made myself using Paint and Gimp.

The current company name is Sevomasean Productions, the same name I used when making flash games in college (and is, as such, open to change since it's a pretty terrible name). Right now I have no real web presence, as it's just me and the 10-15 hours a week I find time to contribute to the project. Once this project has real art and is closer to release, I plan to set up a site (possibly under a new name) and start hyping up the project more. I'm aiming for a holiday release, as my current projects have me done with my end around Halloween (then add testing, extra balancing, etc).

For my resume of past completed games, my Newgrounds site is http://arclite83.newgrounds.com. Please note that Newgrounds cleared out most of the images associated with my account through some cleanup they ran about a year ago, so the only pieces that still have icon art, etc, are the ones that are still linked in featured categories (a defense game, and a valentines day holiday game). The short of it is I made flash games in college for beer money. Now that I'm a professional programmer I'm applying my work skills to making a few apps in my spare time, this being the first of hopefully many.

The Game:

Run an alchemist's potion shop: fill orders, unlock upgrades, become rich and popular, and pay your wizard landlord to keep him from turning you into a frog!

Work fast to assemble customer's orders from a recipe list that starts simple and grows as more ingredients and formulas are unlocked. The simple color-based recipes are easy and intuitive, while the extra ingredients add complexity and keep the game interesting. Fill orders quickly and accurately to earn popularity, giving you more customers and better tips. As you progress, your evil wizard landlord will throw obstacles in your path, such as preventing you from restocking an ingredient or making rounds shorter, to try and ensure your failure. Earn money, restock your inventory, and buy permanent or temporary upgrades from the market that allow you to make even larger profits. Set aside rent money before it's due, or work extra hard right before the wizard shows up to collect; just make sure you have all the money when the time comes, or it's game over!

Easy mode is designed for kids or ultra-casual players, and features no rent and a small credit each round to ensure the fun never ends. Normal is a balanced game, with fair rent and a reasonable number of obstacles to make the game challenging but still relaxing. Hard mode has a much steeper rent curve, and the wizard will repeatedly harass you to try and trip you up. Once you earn enough you can purchase the deed to the potion shop, and be rid of the wizard forever! Afterwards, you can continue to play your character and see how much money you can accrue, or start again on a different difficulty setting.

Amount of work:

This is all approximate based on what I have now and my best guesses for the finished product.
6 background screens
10-12 button animations
~30 icons for inventory items, etc
app launcher icons
5-6 static section backgrounds (popup notices, money, etc)
11 cut scenes (intro and game over scenes, plus 9 short 'announcement' scenes when events happen)
player animations, 6-7 distinct actions
customer animations, 2-3 actions

Compensation:

This is a tough one for me to settle on what is fair (upfront payments vs percentage of profit, etc), and I'm open to talking about it more with the right candidate. Like I said, right now the team is just me, and I'm looking for someone interested in possibly forging a longer relationship for other game app ideas I have. This is not intended to be some big life changing money maker, though I do believe I have something that could do well if it's given enough attention to detail and polish. Also, I don't have a lot of capital for this, it's just a spare time thing right now. I'm much more open to a profit percentage than shelling out buckets upfront. If that's not something you would be comfortable with, I completely understand: people deserve to get paid for their work, and this position will likely NEVER be something you can feed yourself on solely. I certainly don't expect to. The plan is put it on Android, use whatever profits come from that to port it to Apple, and then move on to the next app. If it pays for new laptop, I'll call it a big win.

Job Requirements:

I'm really looking for someone who can draw/animate, be it by hand or computer. I'm looking for a childish, whimsical feel, like a Saturday morning cartoon, or a Disney short, or Bitey of Brackenwood, etc. This is meant to be a game for a generally younger audience, so going for a lighter feel is a big priority for me. I'm especially interested in simple animation samples if you've got them.

Please send inquiries, resumes, and work samples to <REMOVED>. Show me yours and I'll show you mine - quality work samples from interested parties will get a link to the latest beta APK file to load on their Android devices and see what you would be working on.

I look forward to hearing from you!

Friday, September 14, 2012

Clarity over Cleverness: Follow Up, and a Practical Example


I had a nice discussion with a coworker about my last post, and wanted to add some clarity: specifically, the topic was about a piece of unit test code that looked something like this:

        public class UnitTestHere()
        {
                ...
                Db.CreateThing();
                ...
        }

        public static int CreateThing(this IDbCommand command,
                Action<Thing> modifier = null)
        {
            var thing = new Thing
                                    {
                                        ...
                                    };

            if (modifier != null)
            {
                modifier(thing);
            }

            command.Insert(thing);

            return (int) command.GetLastInsertId();
        }

So that, to modify the default values for the 'Thing' in the unit test, users had to do this:

        public class UnitTestHere()
        {
                ...
                Db.CreateThing(t =>
                        t.someField = someNonDefaultValue);
                ...
        }

My argument was along the lines of 'I don't want to have to do this to write my unit test'.

To which I got the reply (very correctly, I might add) that if I don't know it, then I should use it as a learning opportunity. Which is very true. In my case, the argument was less that I didn't know it and more that I felt the delegate was fancy-clever-overkill.

In this case, the code is both clever and clear - it serves a valid purpose, allows people reading the unit tests to see very obviously what values are being set IN the test, and it's dynamic enough to handle changes to any and all value changes in the inserted 'Thing' that might come up in future tests (like the one I was writing that brought about this discussion, for example). The hurdle of writing it is pretty quickly solved, and once the code is there it is clear enough to be maintained / changed with little effort.

I never meant to imply that more advanced tools don't have a place, just that sometimes people use them when they may not be better than the more basic options available to them.

And with that, I'm dropping this one. Happy Coding!