Jul 032011
 

When doing games intended for viral distribution on the web, integrating with Facebook can be a bit cumbersome. The API seems to be primarily meant for used on a site embedded on a Facebook page, and whats worse, with a viral game you will not have control over the embedding page.

Here are a few tricks that will help you overcome those issues. However, there are still a few limitations:

  • Sending invites directly to a friend will not work, since loading the friend selection selection dialog will not be possible unless the embedding page is hosted on the canvas URL
  • It relies on use of ExternalInterface, and hence will not work if allowScriptAccess is set to “never”. The majority of gaming portals do allow external script access, but Kongregate doesn’t and very recently Newgrounds seems to have changed it so they don’t. Mindjolt does allow external script access, but asked me to remove the Facebook functionality.

Also, this is not a complete guide. You probably need decent knowledge of integrating Facebook with Flash to make much sense of it, but hopefully it could give you some ideas if you are struggling with how to get around the various issues involved with accessing the API when you have an application not hosted by yourself.

First of all, unless you plan to only have your swf only on sites that does allow script access you need to check if ExternalInterface works, and if it doesn’t disable your Facebook functionality. One would think that using ExternalInterface.available would let you know that, but it doesn’t tell you if the security sandbox will actually let you use ExternalInterface.
So instead I use the following code:

if (ExternalInterface.call("Date"))
{
	hasScriptAccess = true;
}

If ExternalInterface is available you can include the JS API in the embedding page and add the “fb-root” div. Also, I use a JS function to open the login pop-up. I use the following function to add the necessary JS to the embedding page :

private function addFBJS():void
{
	const script_js:XML =
		<script>
			<![CDATA[
			function () {						
				var body = document.getElementsByTagName('body')[0];
				var head = document.getElementsByTagName('head')[0]; 
				
				var fbDiv = document.createElement('div');
				fbDiv.setAttribute('id', 'fb-root');
				body.insertBefore(fbDiv, body.firstChild);
				
				var fbScript = document.createElement('script');
				fbScript.setAttribute('async', '');
				fbScript.setAttribute('type' ,'text/javascript');
				fbScript.setAttribute('src', 'http://connect.facebook.net/en_US/all.js');
				fbDiv.appendChild(fbScript);

				var fbWinScript = document.createElement('script');
				fbWinScript.setAttribute('type' , 'text/javascript');
				fbWinScript.text = "var fbWin = null; function fbWinIsClosed(){ return (fbWin == null || fbWin.closed); } function openLogin(url) { if (fbWinIsClosed()) { fbWin = window.open(url, 'fbwin', 'toolbar=0,menubar=0,resizable=1,width=800,height=480'); }}";
				head.appendChild(fbWinScript);
			}
			
			]]>
		</script>;
	ExternalInterface.call(script_js);
}

Just make sure to call that function before accessing the API.

One issue that turned out to be problematic was that when using Chrome I kept getting lots of warnings about x-domain access. To solve that you need to make sure that it uses the Flash protocol, instead of the default “fragments”.

I call the following function to handle browser detection and set the protocols as required. I have tested on IE, FF, Chrome and Opera. You might have to switch protocol on Safari as well, but I have not tested it and don’t know what will be used as default. Actual browser detection code is from http://www.quirksmode.org/js/detect.html

private function setXD():void
{
	const script_js:XML =
		<script>
			<![CDATA[
			var BrowserDetect = {
				init: function () {
					this.browser = this.searchString(this.dataBrowser) || "An unknown browser";
					this.version = this.searchVersion(navigator.userAgent)
						|| this.searchVersion(navigator.appVersion)
						|| "an unknown version";
					this.OS = this.searchString(this.dataOS) || "an unknown OS";
				},
				searchString: function (data) {
					for (var i=0;i<data.length;i++)	{
						var dataString = data[i].string;
						var dataProp = data[i].prop;
						this.versionSearchString = data[i].versionSearch || data[i].identity;
						if (dataString) {
							if (dataString.indexOf(data[i].subString) != -1)
								return data[i].identity;
						}
						else if (dataProp)
							return data[i].identity;
					}
				},
				searchVersion: function (dataString) {
					var index = dataString.indexOf(this.versionSearchString);
					if (index == -1) return;
					return parseFloat(dataString.substring(index+this.versionSearchString.length+1));
				},
				dataBrowser: [
					{
						string: navigator.userAgent,
						subString: "Chrome",
						identity: "Chrome"
					},
					{ 	string: navigator.userAgent,
						subString: "OmniWeb",
						versionSearch: "OmniWeb/",
						identity: "OmniWeb"
					},
					{
						string: navigator.vendor,
						subString: "Apple",
						identity: "Safari",
						versionSearch: "Version"
					},
					{
						prop: window.opera,
						identity: "Opera"
					},
					{
						string: navigator.vendor,
						subString: "iCab",
						identity: "iCab"
					},
					{
						string: navigator.vendor,
						subString: "KDE",
						identity: "Konqueror"
					},
					{
						string: navigator.userAgent,
						subString: "Firefox",
						identity: "Firefox"
					},
					{
						string: navigator.vendor,
						subString: "Camino",
						identity: "Camino"
					},
					{		// for newer Netscapes (6+)
						string: navigator.userAgent,
						subString: "Netscape",
						identity: "Netscape"
					},
					{
						string: navigator.userAgent,
						subString: "MSIE",
						identity: "Explorer",
						versionSearch: "MSIE"
					},
					{
						string: navigator.userAgent,
						subString: "Gecko",
						identity: "Mozilla",
						versionSearch: "rv"
					},
					{ 		// for older Netscapes (4-)
						string: navigator.userAgent,
						subString: "Mozilla",
						identity: "Netscape",
						versionSearch: "Mozilla"
					}
				],
				dataOS : [
					{
						string: navigator.platform,
						subString: "Win",
						identity: "Windows"
					},
					{
						string: navigator.platform,
						subString: "Mac",
						identity: "Mac"
					},
					{
						   string: navigator.userAgent,
						   subString: "iPhone",
						   identity: "iPhone/iPod"
					},
					{
						string: navigator.platform,
						subString: "Linux",
						identity: "Linux"
					}
				]

			};
			BrowserDetect.init();

			function() {
				if (BrowserDetect.browser == 'Chrome') {
					FB.XD._origin = window.location.protocol + '//' + document.domain + '/' + FB.guid();
					FB.XD.Flash.init();
					FB.XD._transport = 'flash';
					
				} else if (BrowserDetect.browser == 'Opera') {
					FB.XD._transport = 'fragment';
					FB.XD.Fragment._channelUrl = window.location.protocol + '//' + window.location.host + '/'
				} 
			}
			]]>
		</script>;
	ExternalInterface.call(script_js);
}

To handle the login you need to host a script on the host used for the canvas URL. It’s unfortunate that one needs external dependencies, but otherwise you need to include you app secret in your swf, which is not a great idea.
I use the following PHP script:

<?php
    // your app id
    $app_id = "xxxxxxxxxxxxxxxxxx"; 
    // your app secret
    $app_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; 
    // path to this script, located under the app URL
    $my_url = "http://example.com/facebookLogin.php";

    session_start();
	
    $code = $_REQUEST["code"];
    if (empty($code) && !isset($_GET["getstatus"])) 
	{
        $_SESSION['state'] = md5(uniqid(rand(), TRUE)); //CSRF protection
		
		$dialog_url = "https://www.facebook.com/dialog/oauth?client_id=" 
			. $app_id . "&redirect_uri=" . urlencode($my_url) . "&state="
							. $_SESSION['state'];
		echo("<script> top.location.href='" . $dialog_url . "'</script>");
    } else if ($_SESSION['state']) {
			if (!empty($code))
			{
				$_SESSION['code'] = $code;
			}
			if($_SESSION['code'])
			{
				$token_url = "https://graph.facebook.com/oauth/access_token?"
				  . "client_id=" . $app_id . "&redirect_uri=" . urlencode($my_url)
				  . "&client_secret=" . $app_secret . "&code=" . $_SESSION['code'];

				$response = file_get_contents($token_url);
				
				$params = null;
				parse_str($response, $params);

				$graph_url = "https://graph.facebook.com/me?access_token=" 
				  . $params["access_token"];

				$user = json_decode(file_get_contents($graph_url));
				if(isset($_GET["getstatus"]))
				{
					echo "session=true&uid=".$user->id."&userName=".$user->name;
				} else
				{	
					echo("<script>setTimeout(top.location.href='channel.html', 1000);</script>");
				}
			} else
			{
				echo "session=false";
			}
	} else 
	{
		if(isset($_GET["getstatus"]))
		{
			echo "session=false";
		} else
		{
			echo("<script>setTimeout(top.location.href='channel.html', 1000);</script>");
		}
	}
?>

Notice the “channel.html”. This is a simple HTML file used for two purposes. It acts as a cross-domain scripting channel, and it self-closes the window.
The contents of the file looks like this:

<script src="http://connect.facebook.net/en_US/all.js"></script>
<script>self.close();</script>

Name that “channel.html” and put it at your canvas URL.

Now, to log in in your flash application you have to first initialize:

Facebook.init(_appId, onFBInit, {channelUrl:_appUrl + "channel.html"} );

protected function onFBInit(result:Object,fail:Object):void
{
	if (!result) return;
	_session = result as FacebookSession;
	if (result && result.user)
	{
		_loggedIn = true;
		_session = new FacebookSession();
		_session.user = { name:result.user.name, id:result.user.id };
	} 	
}

_appId is of course the application id you got from Facebook, and _appUrl is the canvas URL where you uploaded “channel.html”.

The Facebook.init callback can be a bit unreliable, but if it works onFBInit will store any current session to avoid the need to log in again.

Then to log in the user, check if the session user is set, otherwise open the popup. Also set a timer to check with PHP when the session cookie has been set.
I use TweenLite for the timer, AS3Signals for events and Destroytoday’s promise to handle async responses. But this is just to give you an idea about how the login process will work, and of course you can use the built in timer and events instead:

public function login(caller:String=null):Promise
{
	_loginPromise = new Promise();
	_checkLoginAttempt = 0;
	if (!_session || !_session.user) 
	{
		openLogin(_appUrl + "facebookLogin.php");
	}
	TweenLite.delayedCall(8, checkLogin);
	return _loginPromise;
}

private function openLogin(loginUrl:String):void
{
	ExternalInterface.call("openLogin", loginUrl);
}

private function checkLogin():void
{
	if (!_session || !_session.user)
	{
		var req:URLRequest = new URLRequest(_appUrl + "facebookLogin.php?getstatus=1");
		var loader:URLLoader = new URLLoader(req);
		var loadedSignal:NativeSignal = new NativeSignal(loader, Event.COMPLETE);
		loadedSignal.add(onCheckLogin);
	} else
	{
		start();
	}
}

private function onCheckLogin(e:Event):void
{
	var loader:URLLoader = e.currentTarget as URLLoader;
	var vars:URLVariables;
	if (_checkLoginAttempt == 5)
	{
		var winClosed:Boolean = ExternalInterface.call("fbWinIsClosed");
		if (winClosed == true)
		{
			_loginPromise.dispatchError("Could not read Facebook cookie. Please try again.");
			_loginPromise.dispose();
			return;
		}
	}
	if (loader.data) vars = new URLVariables(loader.data);
	if (vars && vars.session == "false" && vars.isset == "true")
	{
		_loginPromise.dispatchError("Could not log in to Facebook. Cookie not valid and has been reset. Please try again.");
		_loginPromise.dispose();
		return;
	}
	if (!vars || !vars.session || vars.session == "false")
	{
		_checkLoginAttempt++;
		TweenLite.delayedCall(2, checkLogin);
	} else
	{
		_session = new FacebookSession();
		_session.user = { name:vars.userName, id:vars.uid };
		start();
	}
}

Now everything should be set up and you can do your API calls as usual. For example to post to the wall:

public function postToWall(name:String, 
				    prompt:String, 
				    message:String, 
				    caption:String, 
				    link:String, 
				    imagePath:String):void
{
	var o:Object = 
	{
		user_message_prompt: prompt,
		message: message,
		attachment: 
		{
			media: [{
				type: "image",
				href: link,
				src: imagePath
			}],
			name: name,
			href: link,
			caption: caption,
			description: ""
		},
		action_links: 
		[{ 
		   text: prompt, 
		   href: link 
		}],
		next:_appUrl + "channel.html"
	};
	Facebook.ui("stream.publish", o, null, "popup");
}

As you can see it’s all very obvious, simple and straightforward ;)

I just hope Google+ will take over from Facebook in the not too distant future, since I can’t imagine that their API will be anywhere nearly as tedious to work with.
And of course, keep in mind that this information will likely be outdated in a few months when Facebook decides to make changes to their API once again.

Share/Bookmark

Switch to our mobile site