Edited on February 26, 2012: Scoreoid has recently gone through an API update. The demo here will no longer work, but the changes are not huge. I got my own local copy running well. If anyone wants an updated ColdFusion wrapper, please let me know.
This morning I checked out Jesse Freeman's daily 'Code Warmup' and discovered a new service called Scoreoid. Scoreoid is a free service that provides basic game stat management, specifically it allows you to offload the chore of tracking scores, players, achievements, and game stats. You may ask why you would even need to bother with that. Those tasks wouldn't be too difficult with a server-side language like ColdFusion. But for folks developing mobile games, being able to skip the server completely is very compelling. And as much as I love to write server side code all day, offloading tasks to someone dedicated to it is almost always worth taking a look at. You can see a full list of features on the site itself. Did I mentioned it was free? Yep - I got my email confirmation two minutes after signing up. They will soon be offering a Pro version of the service which will cost money. Oddly you have to sign up and administer your games at their dot com site. So you go to .net for your docs and .com for your console. Once I figured that out though it was smooth sailing. The API is very easy to use and supports both XML and JSON responses.
To test the service out, I decided to build a very simple game. I called it OCD RPG. The game would track one basic stat, a score, and based on that assign a score. It begins by simply prompting for a username:
After entering your username, the game screen is presented.
To play the game, you just click the button. That's it.
Let's take a quick look a the code behind this. The code here really isn't terribly relevant, but I want to show the initial state so you can see how I mark it up later to make use of Scoreoid's API.
First, the HTML:
<!DOCTYPE html>
<html>
<head>
<title>OCD RPG</title>
<link rel="stylesheet" href="bootstrap.min.css">
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script type="text/javascript" src="game.js"></script>
<script>
var game = new gameOb();
//used for animation duration so I can set it SUPER low during testing
var animDur = 500;
$(document).ready(function() {
function drawGameStats() {
$("#scoreSpan").text(game.score);
$("#levelSpan").text(game.level);
}
$("#mainGameButton").click(function() {
game.addScore();
drawGameStats();
$("#logContainer").prepend(game.randomLog()+"<br/>");
});
$("#mainForm").submit(function(e) {
$("#introButton").trigger("click");
e.preventDefault();
});
$("#introButton").click(function() {
var username = $.trim($("#username").val());
if(username == '') {
$("#username").parent().addClass("error");
return;
} else {
$("#username").parent().removeClass("error");
game.init(username);
console.log("Let's get this thing started - user is "+username);
$("#introDiv").fadeOut(animDur, function() {
$("#gameContainer").fadeIn(animDur,function() { drawGameStats(); });
});
}
});
})
</script>
<style>
#mainGameButton {
padding: 20px;
width: 400px;
}
#logContainer {
height: 200px;
overflow:auto;
border-style:solid;
border-width:thin;
}
</style>
</head>
<body>
<div class="container">
<div id="introDiv">
<h1>Welcome to OCD RPG</h1>
<p>
To begin, please enter your username. This will be used to uniquely identify you on our high score list.
</p>
<form id="mainForm">
<fieldset>
<div class="clearfix ">
<input type="text" id="username" placeholder="Username" class="xlarge" size="30">
<input type="button" id="introButton" value="Start Game" class="btn primary">
</div>
</fieldset>
</form>
</div>
<div id="gameContainer" style="display:none">
<h1>Game On!</h1>
<p>
<b>Score:</b> <span id="scoreSpan"></span>
</p>
<p>
<b>Level:</b> <span id="levelSpan"></span>
</p>
<button id="mainGameButton" class="btn">CLICK ME FOR HAPPY ADVENTURE TIME!</button>
<h2>Log</h2>
<div id="logContainer"></div>
</div>
</div>
</body>
</html>
You can see two basic blocks of layout. One handles the initial name prompt and the second the main game screen. The JavaScript file is included below..
var MESSAGES = [
"You looted gold pieces from the dragon's guest house.",
[deleted for space]
"If you hold CTRL while clicking, you get extra points. Honest."
]; this.init = function(user) {
this.username = user;
this.score = 0;
this.level = 1;
} this.addScore = function() {
//add 1-10 points
this.score += this.randRange(5,25)*(Math.ceil(this.level/2));
console.log("need "+this.calculateExperiencePoints(this.level+1)+" for next level");
if(this.score > this.calculateExperiencePoints(this.level+1)) {
this.level++;
}
} //https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Math/random
this.randRange = function(min,max) {
return Math.floor(Math.random() * (max - min) + min);
} //All experience level code credit Jesse Freeman - http://codewarmup.jessefreeman.com/2011/11/07/create-a-rpg-style-exp-progress-bar/#comments
this.calculateExperiencePoints = function(level) {
return (level * (level + 1)) * 100;
} this.randomLog = function() {
return MESSAGES[this.randRange(0,MESSAGES.length)];
}
}
var gameOb = function() {
As the game is rather simple, the only really interesting part is the experience point/level calculator. I used code (well, converted code) Jess Freeman created for an earlier code warmup. I'm treating score essentially like XP, but for naming purposes the game has a score and level property. You can demo this game here:
http://coldfusionjedi.com/demos/2011/nov/17/
Ok, so let's talk Scoreoid. First off, you want to take a look at their wiki to get an idea as to how their API works. You have methods to get basic game stats, score stats, and player stats. I realized rather quickly that my game doesn't really have a score per se. It's not like a game of pong where you play, hit a score, and the game ends. Like most RPGs, it just... goes. Therefore I decided not to make use of the Score API. Instead, I decided I'd use the Player API. This API allows you to add/edit/delete players. Player objects are confined to basic game stats. You can't add ad hoc properties. But the fields supported should cover most gaming needs.
I decided I'd make my game do two things:
- First, it would register the user with Scoreoid. Basically take in the username and add it to their system. I'm not going to bother with passwords, so right now my system isn't secure in any way, but Scoreoid supports storing passwords and user ids and could handle ensuring you don't add the same player twice. Again, I wanted to keep things simple. So when you say your name is Happy Dan, we add you to Scoreoid.
- Record your current level. I mentioned players have multiple fields. There's two related to level I care about - current_level and unlocked_levels. I'll explain why I care about unlocked_levels later.
So with that being said, I began by creating a basic scoreoid CFC I could configure with my AP and game ID. I also wrote a function to handle adding the player. Scoreoid will return an error if the player exists, but since I don't care, I just ignore it.
variables.rooturl = "https://www.scoreoid.com/api/"; public function init(required string api, required string game) {
variables.api = arguments.api;
variables.gameid = arguments.game;
return this;
} public function addPlayer(required string username) {
var result = apiCall("createPlayer", {"username"=arguments.username});
//Note - we should throw an error if they do - but for now, meh
} private function apiCall(required string method,struct args) {
var h = new com.adobe.coldfusion.http(); }
component {
h.setURL(variables.rooturl & arguments.method);
h.setMethod("post");
h.addParam(type="formfield", name="api_key", value=variables.api);
h.addParam(type="formfield", name="game_id", value=variables.gameid);
h.addParam(type="formfield", name="response", value="json");
if(structKeyExists(arguments,"args")) {
for(var key in arguments.args) {
h.addParam(type="formfield", name=key, value=arguments.args[key]);
}
}
var result = h.send().getPrefix();
if(!isJSON(result.fileContent)) throw("Invalid response: #result.fileContent#");
return deserializeJSON(result.fileContent);
}
I created apiCall() to handle running methods against their API in an abstract fashion. You can see how simple this makes the addPlayer call. Since this CFC is going to be cached in the Application scope, I created a new CFC, game, to handle calls from my JavaScript code:
remote function registerPlayer(required string username) { //Ask scoreoid to make the player, we ignore any errors since we aren't doing 'real' auth
application.scoreoid.addPlayer(arguments.username); } }
component {
That handles adding the user. To store my stats, I created a wrapper for updatePlayerField. Oddly Scoreoid doesn't let you update more than one field at a time, so you have to fire off changes to a player one at a time. Here's the wrapper:
public function updatePlayerField(required string username, required string field, required any value) {
apiCall("updatePlayerField", {"username"=arguments.username, "field"=arguments.field, "value"=arguments.value});
}
And game.cfc will get a method to handle this:
remote function storePlayer(required string username, required numeric score, required numeric level) {
application.scoreoid.updatePlayerField(arguments.username, "current_level", arguments.level);
application.scoreoid.updatePlayerField(arguments.username, "unlocked_levels", arguments.level);
}
Looking at this now - I should make my Scoreoid wrapper allow for N calls. Even though the API doesn't, my wrapper could handle it behind the scenes. So that's the back end changes - how about the front end? I now record the user after you hit the initial button:
$.post("game.cfc?method=registerPlayer", {username:username}, function() {
And I also register a 'heart beat' that pings the server every few seconds.
$.post("game.cfc?method=storePlayer", {username:game.username, score:game.score, level:game.level});
You can demo this version here: http://coldfusionjedi.com/demos/2011/nov/17/draft2/.
Right away, let me make sure it's clear to everyone that I know this system can be hacked. Easily. I'm sure folks will. I ask that you don't. But I know folks will. -sigh- With the basics built, I played a bit and went backto the Scoreoid site. Their dashboard includes some very nice reporting tools.
Here's an example of their basic stats:
You can also dig deeper into game stats and see player info as well. Remember earlier when I mentioned I needed to record "unlocked_levels"? When it comes to getting game stats, they only track a subset of player properties. One of them is unlocked_levels. So by tracking that too, I was always to get that data. I wrote wrappers to get top, average, and lowest game stats for a particular game field:
public any function getGameLowest(required string field) {
return apiCall("getGameLowest",{"field"=arguments.field}).number;
} public any function getGameTop(required string field) {
return apiCall("getGameTop",{"field"=arguments.field}).number;
}
public any function getGameAverage(required string field) {
return apiCall("getGameAverage",{"field"=arguments.field}).number;
}
And in ColdFusion I could now do:
<cfset toplevel = application.scoreoid.getGameTop("unlocked_levels")>
<cfset lowlevel = application.scoreoid.getGameLowest("unlocked_levels")>
<cfset avglevel = application.scoreoid.getGameAverage("unlocked_levels")>
I also made a call to get players. You can't sort by unlocked_levels, so I made use of QuickSort from CFLib. This allowed the creation of a basic stats page: http://coldfusionjedi.com/demos/2011/nov/17/draft2/stats.cfm.
That's it. Here's my current Scoreoid CFC if anyone wants to run with it.
variables.rooturl = "https://www.scoreoid.com/api/"; public function init(required string api, required string game) {
variables.api = arguments.api;
variables.gameid = arguments.game;
return this;
} public function addPlayer(required string username) {
var result = apiCall("createPlayer", {"username"=arguments.username});
//Note - we should throw an error if they do - but for now, meh
} public any function getGame() {
return apiCall("getGame")[1].game;
} public any function getGameAverage(required string field) {
return apiCall("getGameAverage",{"field"=arguments.field}).number;
} public any function getGameLowest(required string field) {
return apiCall("getGameLowest",{"field"=arguments.field}).number;
} public any function getGameTop(required string field) {
return apiCall("getGameTop",{"field"=arguments.field}).number;
} public array function getPlayers() {
var res = apiCall("getPlayers");
var result = [];
for(var i=1; i<=arrayLen(res); i++) {
arrayAppend(result, res[i].Player);
}
return result;
} public function updatePlayerField(required string username, required string field, required any value) {
apiCall("updatePlayerField", {"username"=arguments.username, "field"=arguments.field, "value"=arguments.value});
} private function apiCall(required string method,struct args) {
writelog(file="application", text="#serializejson(arguments)#");
var h = new com.adobe.coldfusion.http(); }
component {
h.setURL(variables.rooturl & arguments.method);
h.setMethod("post");
h.addParam(type="formfield", name="api_key", value=variables.api);
h.addParam(type="formfield", name="game_id", value=variables.gameid);
h.addParam(type="formfield", name="response", value="json");
if(structKeyExists(arguments,"args")) {
for(var key in arguments.args) {
h.addParam(type="formfield", name=key, value=arguments.args[key]);
}
}
var result = h.send().getPrefix();
if(!isJSON(result.fileContent)) throw("Invalid response: #result.fileContent#");
return deserializeJSON(result.fileContent);
}