Jim O'Neil
Technology Evangelist
Join App Builder
Keep The Cash! Earn $100 for every app you publish! Let me know how I can help!
As I was pulling together my previous post on notifications in an HTML 5 game on Windows 8, I couldn’t help but think there must be a better way to handle the storage of the local leaderboard. If you’re not familiar with that sample, it’s a simple game where you see how many times you can touch a bouncing ball before it hits the boundaries of the display ten times.
The sample implements a local leaderboard that leverages the roaming data store in Windows 8 application data storage, as you can see from this snippet of code in the Scores.setScores method:
var roamingSettings = Windows.Storage.ApplicationData.current.roamingSettings; roamingSettings.values["scores"] = JSON.stringify(scoresToSet);
Here scoresToSet is an array of JavaScript objects that each contains a player, score, and skill level, like
{ player:"Jim", score: 32, skill: 1 }
Since roaming storage is synced to the cloud, the current implementation brings the leaderboard to whatever other devices the user has installed the game on, and that may or may not be a feature you’d want to expose. After all the game may be harder to play on different form factors, touch versus mouse, etc., so does consolidating the leaderboard into one across those disparate devices make sense? If not, it would be a trivial matter to switch to local application data storage, simply by referencing Windows.Storage.ApplicationData.current.localSettings instead of roamingSettings.
That’s not the part that bothered me though; it was this code:
for (var i = scores.length - 1; i >= 0; i--) { if (score.score > scores[i].score) { if (i < scores.length - 1) { scores[i + 1] = scores[i]; scores[i] = score; newScoreRank = i + 1; } if (i === 0) { scores[i] = score; newScoreRank = 1; } } else if (score.score === scores[i].score && i < scores.length - 1) { scores[i + 1] = score; newScoreRank = i + 2; break; } }
Granted, this is just a sample application, but look at the amount of code needed to manipulate the leaderboard and insert the new score at the right place, so it can later be serialized en masse into roaming storage. On top of that, there’s limited capability to show other interesting views of the game play, like a history of all the games Joe completed, or a leaderboard that’s filtered by skill level.
To get the flexibility desired, we need to store more than just the snapshot of the leaderboard, and we need some capability to do at least rudimentary ad hoc queries on the data. That’s the kind of thing that SQL Server would be good for! Of course, in the context of Windows Store applications, much less the JavaScript variety, SQL Server and relational database servers aren’t a supported option, unless hosted as a service.
The good news is that JavaScript applications built in Windows 8 can leverage the Indexed Database API specification of the W3C, or IndexedDB for short. IndexedDB is a non-relational object store of JavaScript objects providing transactional semantics via the following constructs:
If you've worked with relational database management systems before, these concepts should be familiar, but be aware there are no relational semantics here – that means no joins! If you've been tinkering with some of the key-value NoSQL offerings, that won’t be a shock and you'll be right at home.
By the way, if you’re looking for relational database-like functionality for your C# or VB Windows Store app, take a look at SQLite (and in particular the excellent blog posts by Tim Heuer). With SQLite you essentially embed a transactional SQL engine right into your application’s process! There’s a port of SQLite to JavaScript as well, but I’ve not explored its viability for Windows Store applications. Lastly, for those of you familiar with Web SQL,note that the W3C has ceased work on that specification.
The leaderboard in the current HTML 5 sample contains objects with a consistent schema of three properties (or attributes):
player: the name of the player score: the score of the game skill: the skill level of the associated game (from 0 to 2, Basic to Advanced)
player: the name of the player
score: the score of the game
skill: the skill level of the associated game (from 0 to 2, Basic to Advanced)
One challenge of this object structure is that uniqueness is not necessarily guaranteed. I could score the same on two games played at the same level. In this case, I’d be compelled to add a timestamp that indicates when the game was completed, and that’s something I’d probably want to show on the leaderboard anyway - the longer someone at the top the more compelling it is to try to beat them!
A timestamp (in UTC) will work fine as a key in this case, since it will be unique on that device. But you could also opt to use a key generator which simply creates a monotonically increasing sequence of values (up to 2^53) whenever a key is needed.
Now let's revisit the core IndexedDB constructs and put some concrete names against them that will be used in the actual code:
To upgrade the leaderboard to use IndexedDB, we simply need to replace the implementation of the methods that currently access roaming storage, and that code is localized to three methods located in scores.js
We need to start by creating a database if one doesn’t exist (and it won’t the first time the application is run on a Windows 8 device). A good spot for doing this in the HTML 5 game’s application framework is the initialize method of the Game module (in game.js):
1: initialize: function (state) {
2:
3: if (GameManager.gameId === null) {
4: this.stateHelper = state;
5: this.state = state.internal;
6: this.settings = state.external;
7: }
8:
9: var db = window.indexedDB.open("leaderboard", 1);
10: db.onupgradeneeded = function (e) {
11: GameManager.leaderboard = e.target.result;
12:
13: scoresTable = GameManager.leaderboard.createObjectStore( "scores", { keyPath: "gamedate" });
14: scoresTable.createIndex("playerIdx", "player", { unique: false });
15: scoresTable.createIndex("scoreIdx", "score", { unique: false });
16: scoresTable.createIndex("skillIdx", "skill", { unique: false });
17:
18: txn = e.target.transaction;
19: txn.onerror = function () { console.log("Schema definition failed"); };
20: txn.oncomplete = function () { console.log("Schema definition worked"); };
21: };
22: db.onerror = function () { console.log("Database creation failed"); };
23: db.onsuccess = function (e) {
24: GameManager.leaderboard = e.target.result;
25: };
26: },
The database is empty at this point, and records to be added whenever a player completes a game and a new score is logged. That happens in the newScore method of the Scores module (scores.js). Where the code had pulled the last known leaderboard from roaming storage and manually inserted the new score (if it appeared in the top ten), now the new score record is simply inserted into the database:
1: newScore: function (score) {
3: var txn = GameManager.leaderboard.transaction(["scores"], "readwrite");
4:
5: scoreTable = txn.objectStore("scores");
6: insRequest = scoreTable.add(
7: {
8: player: score.player,
9: score: score.score,
10: skill: score.skill,
11: gamedate: new Date()
12: });
13: },
In the original implementation, the code flow to populate the leaderboard went something like this:
The scoresPage UI contains a ListView that is bound to the JavaScript array (the ViewModel) returned by getItems in scoresPage.js. getItems in turn invokes getScores to pull the last snapshot of the leaderboard from the roaming settings, essentially grabbing the Model and creating the ViewModel. All of this happens in a synchronous fashion. In the schematic above, the invocation chain is captured by the blue solid arrows and the data that is returned is represented by the red dashed arrows.
To adapt this workflow for IndexedDB, conceptually the only change is to have getScores access IndexedDB versus roaming storage. It’s not quite that easy though: data access for IndexedDB all occurs asynchronously, and the existing code is expecting getScores to block until it has all the data. So, instead of returning a JavaScript array to getItems, we pass callback functions forward. When the query for IndexedDB had completed, its callback fires and in turn spawns the execution of the forwarded callbacks, which set up the ViewModel and trigger the binding. The flow now looks more like:
As you might expect there is a bit of a ripple effect in the code as both getItems and getScores need to participate in the asynchronous invocation chain by accepting callbacks to execute. getScores (implementation below) now accepts a callback function (passed to it by getItems) that is invoked when the database retrieval is complete. That callback function (shown a bit later) takes the data and creates the ViewModel, and when that completes, it invokes another callback that sets up the ListView binding.
The IndexedDB code though is confined to the getScores method, and there are three primary constructs in play:
1: getScores: function (populateViewModel) {
3: var scoresFromDb = [];
5: var txn = GameManager.leaderboard.transaction(["scores"], "readonly");
6: txn.oncomplete = function () {
7: populateViewModel(scoresFromDb);
8: };
9:
10: var query = txn.objectStore("scores")
11: .index("scoreIdx")
12: .openCursor(null, "prev");
13: query.onsuccess = function (e) {
14: var cursor = e.target.result;
15: if (cursor) {
16: scoresFromDb.push(cursor.value);
17: cursor.continue();
18: }
19: };
20: },
For completeness sake, the revision to getItems follows. Instead of getScores returning a list of scores, an anonymous function that populates the ViewModel is passed in (Lines 6 – 24), so that when the scores are retrieved from the database, that function can be invoked to set the values in the ViewModel.
1: function getItems(updateBindings) {
2: // TODO: Update desired background styling values of the score table here
3: var colors = ["rgba(209, 211, 212, 1)", "rgba(147, 149, 152, 1)", "rgba(65, 64, 66, 1)"];
5: var scores = GameManager.scoreHelper.getScores(
6: function (scores) {
7: var items = [];
8: var groupId = 0;
10: // TODO: Update to match your score structure
11: for (var i = 0; i < scores.length; i++) {
12: items.push({
13: group: pageData.groups[groupId],
14: key: "item" + i,
15: rank: i + 1,
16: player: scores[i].player,
17: score: scores[i].score,
18: skill: GameManager.scoreHelper.getSkillText(scores[i].skill),
19: backgroundColor: colors[i % colors.length],
20: });
21: }
22:
23: updateBindings(items);
24: });
25: }
getItems (below) similarly accepts a function as an argument, and that function is how the newly populated ViewModel (items) is made accessible to the page initialization code that sets up the UI binding. There is another invocation of getItems in this same JavaScript module that must be similarly modified, but I’m leaving the details out since it’s tangential to the discussion of IndexedDB.
1: function ready(element, options) {
3: // Set up ListView
4: WinJS.UI.processAll(element)
5: .done(function () {
6: if (list) {
7: var listView = element.querySelector(".collectionList").winControl;
8: pageData.groups = getGroups();
9: getItems(function (items) {
10: pageData.items = items;
11: list = new WinJS.Binding.List(pageData.items);
12: groupedItems = list.createGrouped(groupKeySelector, groupDataSelector);
13: listView.forceLayout();
14: });
15: }
16: });
17: }
Now that the revamped leaderboard has feature parity with the original implementation, let’s add some new functionality. The current game has three levels, Basic, Intermediate, and Advanced, so it might be nice to have an option to display leaderboards for each of those levels individually. Let’s assume that there is some UI element, a three-valued slider for instance, that is introduced allowing the user to pick the skill level reflected on the leaderboard. The value obtained from that UI element could be made accessible to the getScores function, perhaps as an argument.
The operative part of getScores is reproduced below; this is the same version we discussed above that retrieves all the scores in descending order.
var query = txn.objectStore("scores") .index("scoreIdx") .openCursor(null, "prev"); query.onsuccess = function (e) { var cursor = e.target.result; if (cursor) { scoresFromDb.push(cursor.value); cursor.continue(); } };
Recall that when the database was created, we set up three different indexes, including one on skill level, so one approach would be to use that index and confine the elements retrieved – the key range – to the skill level that was selected. That query would look something like the following, with the changes highlighted:
var query = txn.objectStore("scores") .index("skillIdx") .openCursor(IDBKeyRange.only(skillLevel), "prev"); query.onsuccess = function (e) { var cursor = e.target.result; if (cursor) { scoresFromDb.push(cursor.value); cursor.continue(); } scoresFromDb.sort(function (a, b) { return b.score - a.score; }); };
Since we want only the score records associated with the level selected (available here as skillLevel), it makes sense to use the index defined on that attribute of the score objects. Furthermore, we need only a subset of the range that index spans. In this case, it’s a single value, so we can use the only key range. You can also specify other key ranges bounded (or not) on either the high or the low ends of the range of values that the index spans. With only a single key value being returned, the cursor mode isn’t really relevant, so that optional argument (with the value prev) could be removed from the call to openCursor.
You might be surprised to see a call to sort the array! Since IndexedDB does not support compound indexes, you can’t easily do what you might do in SQL with a ORDER BY clause specifying two columns. By going with skillIdx, we got the benefit of the cursor returning only those scores for the desired game skill level, but there is no guaranteed ordering of them. The leaderboard needs to display them in descending order, so that’s why the call to the built-in JavaScript sort method is necessary.
An alternative would be to stay with the original index (scoreIdx), which does sort by score in descending fashion, but only add to scoresFromDb those values that have the correct skill value. A simple if condition (if (cursor.value.skill == skillLevel)) prior to pushing the value to scoresFromDb would suffice.
Hopefully, the sample scenario here has provided some insight into how you might leverage IndexedDB in your own applications, games or otherwise. Keep in mind that it is just one of several storage options for JavaScript Windows Store applications, and each has its own unique characteristics and constraints. For IndexedDB in particular note:
It could get tedious to write IndexedDB code by hand - the LINQ2IndexedDB could be a good alternative -
linq2indexeddb.codeplex.com