Friday, January 10, 2014

IndexedDB (Game Programming) - revised

Sometimes I can't let an idea go. I wrote a simple post on Local Storage and thought that would be all that needed to be said in terms of saving high scores for your game! Well, it turns out that Local Storage has been proved harmful (just like GOTO) and that you don't want to use it in Firefox OS. Synchronous programming is not good!

Lucky for us, the Firefox OS team has created a small library called async_storage.js that covers the same territory as Local Storage. I wrote about it here: http://firefoxosgaming.blogspot.com/2014/01/asynchronous-storage-game-programming.html. Done!

Well, not quite, because there were a few nits I had to add about how it worked. And there was that nagging feeling that maybe I should dig into IndexedDB, which is what async_storage.js is based upon. As a low-level kind of guy, I like knowing what's under the hood. Well, IndexedDB isn't simple, but it's not really that hard. And it's a great tool to add to your toolbox. Not just for saving high scores, but for any complicated stuff. I remember when I worked on a large adventure game in the 80's and the head of the project said that adventure games are really all about databases!

Okay, so what is IndexedDB? Essentially it is a database that can be stored on your phone. When you turn off the phone and come back later, the data is still there. But there are a few different types of databases (highly simplified).

Flat

Flat databases are just tables. You have rows and columns and you put data in and get it back later. Specify the type and width of the columns and you're done. This is how Excel and other spreadsheets work. You've all seen that somewhere, I'm sure. Are there any spreadsheets available for Firefox OS?

Relational

In the 90's, relational databases came into their own and became standard. Essentially you have a bunch of tables and you relate them to each other. If you specify your data carefully, you can do very powerful things. Or mess things up quickly. The language that most people use for this is SQL (Structured Query Language). Right now this is what most corporations use.

No-SQL

A recent trend is to do away with SQL and make simplified databases that work with key-value pairs. They don't require SQL and these databases are hot right now. IndexedDB is one of these. If you'd like to know more about no-SQL databases, I recommend a cool book called Seven Databases in Seven Weeks: A Guide to Modern Databases and the NoSQL Movement. A nice read an an eye opener if all you know about databases are flat or relational.

IndexedDB

IndexedDB is supported in Firefox OS, Firefox desktop, Firefox for Android, Chrome, Internet Explorer, but not Safari and a few other mobiles. We only care about the ever-growing Firefox family and we're good with IndexedDB.

As usual, the Mozilla Developer Network (MDN) covers IndexedDB well. Start at a great overview here: https://developer.mozilla.org/en-US/docs/IndexedDB/Basic_Concepts_Behind_IndexedDB. You'll want to browse through the large API starting here: https://developer.mozilla.org/en-US/docs/IndexedDB.

Two tips for reading the docs:

  1. The docs are laid out as interfaces (for example, IDBObjectStore). You're not going to type that. You're going to type something.objectstore.something. The IDBxxx name is for C++ developers and that's not you. That's really more for the Mozilla developers and the original W3C specification.
     
  2. The sample code is confusing and might be out of date. The reference has lots of code snippets with references to earlier definitions not included. You can figure it out, but because IndexedDB is still pretty new, there aren't a lot of helpful samples and tutorials elsewhere. Part of that is that there are several ways to skin this kind of cat, and the API is complicated. And because this is a different type of database, some things aren't always there or named what you think they might be. But it does work! Not really a criticism of MDN, because I know how much they have to do and how little resources they really have!
So with that said, the main thing to understand is that you store your data as key-value pairs. The value is the data you want to store and retrieve, and key is the label for that data. For example, you might have a bunch of names for your pets. The names would be keys and the type of pet would be the data. You want unique keys (unless all your pets are named Spot) and each key will identify a particular pet. So "Rover" would be your "dog" and "Felix" would be your "cat". You can also index your data so "Pets" might be your key and Pets[1] would be a particular pet. There are a lot of ways to skin these pets (please forgive the American slang). Indexing and other manipulation can be very powerful. And everything is asynchronous, so you won't have problems with waiting.

Indexed DB Steps


Indexed DB requires a few steps that you will use every time. Learn them and it is actually pretty easy. Here are the steps:
  1. Open your database. If there is no database, one will be created for you.  You need to specify a name and a version. Tip: don't use decimals for your version.
  2. Create a store. You can have more than one store in your database. Think of it as a table. You could have one store for your cats and another for your dogs. You only need to do these first two steps once. Each time you change the version, you need to do it over again. You'll need to specify the store name and a keyPath. The key path (or just key) is just the way you will sort your data. Make sure that the key will be unique for your data. If you have two dogs named Spot, you're in trouble.
  3. Set up a transaction. A transaction is a specific change to the database. You're going to add, put, get, or delete data, using key-value pairs. But before you can do that, you must set up the transaction. To set up the transaction, you need the store name. All transactions are tied to a specific store.
  4. Add, put, get, and delete your data. These operations make up a transaction. You can probably figure out what each operation does, but add will add to the database while put just replaces something that is there (or adds it if nothing is there). The commands take different parameters, depending on what you are doing. For example, add and put will require a key and value, but a get and delete only requires the key.
     
  5. Check to see if errors happen. You'll want to do this. Always. Luckily you can set up events easily to do this. onsuccess and onerror are your friend.
Essentially you'll set up things so you only do steps 1 and 2 once for every database version, and the onupgradeneeded event takes care of that for you. Steps 3 and 4 you do each time you want to add, put, get, or delete data. There's plenty more and you can have fun with indexing all day. But don't forget to do step 5 for every action you take. Be careful out there, people!

I've created a very simple sample that will show you the basic code to do IndexedDB. I'm only creating one database, one store, and one item (with one key-value pair). In keeping with game design and making this really, really simple, I'm just storing the value of a score, letting you generate random scores, and then storing them in the database and getting them back again and displaying the results.

The Code

So here's the code, tested and run on the Geeksphone Peak with Firefox OS 1.1.

<!DOCTYPE HTML>
<html>
 
  <head>
    <meta charset="UTF-8">
    <title>
      IndexedDB
    </title>
 
    <script type="text/javascript">
   
    // Global variables.
    var highScore = 0;
    var myDb;                    // Database object
    var myDatabase = "FoxBase";  // Database name
    var myVersion = 1;           // Database version
    var myStoreName = "myScore"; // Database store name
    var myStoreObject;           // Database object store
    var myTrans;                 // Database transaction 
    var flag = 0;                // Flag for initial data.   

        // Load event listener
      window.addEventListener("load", getLoaded, false);
       
      // Function called on page load.
      function getLoaded() { 
     
        // Save button listener
        myButtonSave.addEventListener(
          "mousedown", myClickSave,false);

        // Retrieve button listener
        myButtonLoad.addEventListener(
          "mousedown", myClickLoad,false);
         
        // Clear button listener
        myButtonClear.addEventListener(
          "mousedown", myStorageClear,false);
         
        // Length button listener
        myButtonLength.addEventListener(
          "mousedown", myStorageLength,false);       
         
        // Background color
        document.body.style.backgroundColor = "Coral";
       
        // Generate a random score.
        randomScore = Math.floor(Math.random()*10000);
        info.innerHTML = "High score: " + randomScore;
       
        // Initialize with name and version.
        // If no database exists, one will be created.
        myRequest = window.indexedDB.open(myDatabase,
          myVersion);
            
        // This is called on version change.
        // Fired first time database is created.
        // Not fired again unless new version created.
        myRequest.onupgradeneeded = function(e) {
       
          // Save result of version change.
          myDb = e.target.result;
         
          flag = 1; // First time through.
         
          // Kill duplicate name if there is one.
          if(myDb.objectStoreNames.contains(myStoreName)) {
            myDb.deleteObjectStore(myStoreName);
          }
         
          // Make an object store.
          myObjectStore = myDb.createObjectStore(myStoreName,
            { keyPath: "initials" });
           
              // Provide feedback.
              console.log(myStoreName + " object store added!");
            }
                       
        // Was the request successful?
        myRequest.onsuccess = function(e) {
         
          // Provide feedback.
              // Database name and version.
          console.log(myDatabase + " version: " +
            myVersion + " loaded!");
              // Database state.
          console.log("Database request state: " +
            myRequest.readyState + "!");
           
          // Save reference to database.
          // You need this if not first time.
          myDb = e.target.result;        

          // Initialize data only if first time.
          if (flag==1) {
         
            // Initialize the score.   
                // Get the transaction.
                myTrans = myDb.transaction([myStoreName],
            "readwrite");         
            // Get the store object.
                myStoreObject = myTrans.objectStore(myStoreName);
         
            // Put the data you want to save into the store.
                // The key is "initials" and the data is "score".
                // Insert the current random score.         
            myStoreObject.put({
              "score" : highScore,
              "initials" : "ABC"
            });   
         
                // Provide feedback.
                console.log("Score initialized to zero.");
          }           
        }
       
        // Did the request fail?
        myRequest.onerror = function(e) {
       
          // Provide feedback.
          alert("Error: " + e.target.errorCode);       
        }

            // Page loaded and initialized.
        console.log("The page is loaded!");
      }
     
      // Put button event handler
      function myClickSave() {
     
          // Open a transaction on the database.
        myTrans = myDb.transaction([myStoreName],
          "readwrite");
       
            // Get the store object.
        myStoreObject = myTrans.objectStore(myStoreName);
       
            // Put the data you want to save into the store.
            // The key is "initials" and the data is "score".
            // Insert the current random score.
        myRequest = myStoreObject.put({
          "score" : randomScore,
          "initials" : "ABC"
        });   
       
            // Provide feedback.
            console.log("New score added!");   
        putScore.innerHTML = "New score added!";     
        }
     
      // Get button event handler
    function myClickLoad() {
     
        // Open a transaction on the database.
      myTrans = myDb.transaction([myStoreName]);
       
          // Get the store object.
      myStoreObject = myTrans.objectStore(myStoreName);
       
          // Get the item name corresponding to the "initials".
      myRequest = myStoreObject.get("ABC");
       
          // Did we get the score?
          myRequest.onsuccess = function(e) {
       
            // Display the actual score we got.
            console.log(myRequest.result.score);
        getScore.innerHTML = myRequest.result.score;       
          }    
    }
     
      // Delete button event handler.
      function myStorageClear() {
     
        // Delete the database.
        // Use this only for testing!
        window.indexedDB.deleteDatabase(myDatabase);
       
        // Oh, noes, you killed the database!
        console.log("You deleted the whole database!");      
      }
 
      // Create new score button event handler.
      function myStorageLength() {

        // Generate a random score.
        randomScore = Math.floor(Math.random()*10000);
        info.innerHTML = "High score: " + randomScore;
      }
      
    </script>
  </head>
 
  <body>
 
    <p id="info">Watch this space!</p>
   
    <p>Put something in the database.</p>
    <p id="putScore">PUT SCORE</p>
   
    <p>Get something from the database.</p>
    <p id="getScore">GET SCORE</p>
   
    <button id="myButtonSave">Put Score</button>
    <br><br>
   
    <button id="myButtonLoad">Get Score</button>
    <br><br>
   
    <button id="myButtonClear">Delete Database</button>
    <br><br>
   
    <button id="myButtonLength">Create New Score</button>

  </body>

</html>


This is about the smallest I could boil it down to and still have a complete example that lets you see the putting and getting happen on your phone. If you close the application and kill it, the data will still be there the next time you load the app. Magic!

What It Looks Like

 When you load the app into your Firefox OS phone, here is what it looks like:


The top line is the high score. The next two lines will tell you about the Put operations and the two after that will tell you about Get operations. The four buttons will Put the score into the database, Get the score from the database, delete the database, and generate a new score from a random number.



The above screen shows what happens if you press the Get button. The initial score is set to zero, so if you get it, that is what you get, and it is displayed below the heading "Get something from the database." The randomly generated high score is at the top, but is not yet in the database.


The above screen shows what happens when you press the Put button. Under the "Put something in the database" it says "New score added!" You're just being notified that a score was added. The data that was added was whatever the high score was, as displayed at the top of the screen.


The above screen shows what happens if you now press the Get button to see what you stored with the Put button. You'll see that the number is the same as the high score.

The "Create New Score" button just generates a new high score randomly, letting you save it with the Put button if you want to test it.

The "Delete Database" was something I put in for testing. It just deletes the database. You only need this if you are changing the code and need to delete the database to start over again with the same version. If you delete the database, you'll need to completely kill the app and start over again. On the phone, you do a long press on Home and tap on the upper-left-hand corner of the app you want to kill.

How Does It Work?

This is just a simple HTML5 page with buttons and text in the body. I had a bunch of globals and event listeners for the buttons and their corresponding event handlers. The code starts in the handler that is loaded when the page loads.

Step 1 - Open the Database

The first step for Indexed DB is to open the database.

        myRequest = window.indexedDB.open(myDatabase,
          myVersion);


You just open a database with a name and version (defined in the globals). The result of this will be stored as a request that you can use to  use to see if your request to open worked.

Step 2 - Create a Store

       myRequest.onupgradeneeded = function(e) {
       
          myDb = e.target.result;
         
          if(myDb.objectStoreNames.contains(myStoreName)) {
            myDb.deleteObjectStore(myStoreName);
          }
         
          myObjectStore = myDb.createObjectStore(myStoreName,
            { keyPath: "initials" });
           
              console.log(myStoreName + " object store added!");
            }

You want to do this only when the onupgradeneeded event takes place, and that takes place once in a lifetime for each version of your database. First you see if there is a store with that name already, and if there is, you delete it and make one with the same name. You want to be careful. Who knows what is already hidden away in that database? The store is created with a name and a keyPath. The name can be anything and I chose "initials" as my key because it might be fun to expand this and let people save scores with their initials, just like in the arcade machines. I also added a console.log statement that says the store was added. Maybe it wasn't really, but with JavaScript, if a log doesn't print out, something went wrong. But fortunately there's a way to tell what happened.

Step 2.5 - Did it happen?

        myRequest.onsuccess = function(e) {
         
          console.log(myDatabase + " version: " +
            myVersion + " loaded!");
          console.log("Database request state: " +
            myRequest.readyState + "!");


          myDb = e.target.result;


This code will be triggered if the creation was successful. It logs the database name and version, and also the ready state. The ready state will tell you if the loading is over or still happening.

I also added a reference to the database itself here with the variable myDb. This same line was in the onupgradeneeded event handler. Why twice? Because if I just have it in the onupgradeneeded handler, it will only get created the first time through. That makes it difficult to use again for the button event handlers, who also use myDb to get a reference to the database. You can only get that reference by opening the database and using a handler. Because the onupgradeneeded handler is asynchronous, you can't assume that it will get the reference from the database open. So doing it twice is the only way to make sure! With asynchronous programming, you must be sure. You can't assume.

Step 3 - Start a transaction

Now that the database and store are prepared, it's time to do a first transaction, to set the score to zero. We're only going to do this once. You want this code in the onsuccess event handler because it might not run if you try to do it in the onupgradeneeded handler. But that handler gets called when the database is opened (every time you run the app) and again when you create the store inside the upgradedneeded handler. So my hackish solution is to create a flag that starts out at 0 as a global and is set (set means flag =1) inside the onupgradeneeded handler. Then when the success event handler is called, the flag will determine whether the initializing transaction takes place.

                                flag = 1; // First time through.

Then, in the onsuccess event handler, you start the transaction like this:

             if (flag==1) {

               myTrans = myDb.transaction([myStoreName],
                 "readwrite");                     

This will open a transaction. Notice that the store name has brackets around it, indicating that it is really an array. We're only using the first item in the array, but the transaction folks like to know these things. The transaction is readwrite, indicating that you can read or write.

Note that this transaction is actually part of the onsuccess event handler. You don't want to even try to create something if the object store wasn't successfully created.

Step 4 - Put the data

          myStoreObject = myTrans.objectStore(myStoreName);
                  
          myStoreObject.put({
            "score" : highScore,
            "initials" : "ABC"
          });   
         
          console.log("Score initialized to zero.");

        }

This is still part of the onsuccess event handler. First you get the object store by using its name and then you put the data in that store using a key-value pair. The key is "initials" and you must always use this to store your data, and you must make sure that each key is unique. For simplicity, I chose simple keys and values, but for more complicated data, you'll want to have an index that is unique so that all your data is solid.

Notice that the commands are tied not only to the specific store, but to a specific transaction.

And note, also, that this initialization only takes place once. If you kill the app and come back later, the data will not be initialized but will be whatever you last saved it as.

Finally, I used a console message to say that the Put took place and what it was about.

Getting the Data

I have a button that you can press to get the current value in the database of the score. Here's the code.

    function myClickLoad() {
     
      myTrans = myDb.transaction([myStoreName]);
       
      myStoreObject = myTrans.objectStore(myStoreName);
       
      myRequest = myStoreObject.get("ABC");
       
      myRequest.onsuccess = function(e) {

        console.log(myRequest.result.score);
        getScore.innerHTML = myRequest.result.score;       
      }    
    }


This event handler creates a transaction using values from the global variables. This needs the database and store name. The database name was created when you initialized the database with onupgradeneeded. You need to store these as globals and the variables are saved with the database.

This is similar to the initialization of the score. You get your transaction, get your store, and then you get your data, based on the "initials" key. The particular key is called "ABC" and the Get gets it. Get it? Then you see if you got it with the event handler. If you did, the score will be:

       myRequest.result.score

The "score" is the matching part of the pair that corresponds to "initials". The result is the result of the event, which was to get the data.

Putting the Data

This is similar to getting the data.

      function myClickSave() {
     
        myTrans = myDb.transaction([myStoreName],
          "readwrite");
       
        myStoreObject = myTrans.objectStore(myStoreName);
       
        myRequest = myStoreObject.put({
          "score" : randomScore,
          "initials" : "ABC"
        });   
       
          console.log("New score added!");   
          putScore.innerHTML = "New score added!";     
      }


You start a transaction, get the store, save the data with a key and value. The names of the key and value pairs are "initials" and "score" and the data that is saved in this case are "ABC" and the value of randomScore.

Deleting the Database

Don't try this at home! You only should do this while you are programming, but I thought I'd leave it in for your use. Once a database is deleted, you need to close the browser and kill it thoroughly dead.

     function myStorageClear() {
     
        window.indexedDB.deleteDatabase(myDatabase);

        console.log("You deleted the whole database!");      
      }

Again, use global variables.

Generating a New Score

I put this in a button event handler.

     function myStorageLength() {

        randomScore = Math.floor(Math.random()*10000);
        info.innerHTML = "High score: " + randomScore;
      }

Ignore the function name and button id names. I wanted to use the same UI as my earlier examples. Probably not a good idea. There isn't any method or property that I could find for IndexedDB that tells how many items are in a store. Maybe that's by design?

This just uses good old Math to make a random number and puts it on the screen.

That's It!

I hope you enjoyed this somewhat winding trip down the database lane. IndexedDB isn't that hard to use but it satisfies all the requirements for good database management in HTML5 and is completely supported by Firefox OS.

(Well, I take that back. There's a synchronous version of IndexedDB that will be tailored for Web Workers, but it's not ready yet. Web Workers are cool but that's the subject of a different post, later, much later.)

Now I'm back to review some games and then to create a simple game and see if I can put it in the Marketplace. I'll do that over a series of posts. Stay tuned, but not iTuned!

Not Quite

If you read this in the last few hours, I'm sorry to tell you that I made two errors my my logic.
  1. The score gets initialized every time. The code is the right place, you can't read and write data until the onsuccess event fires. The problem is that the same event is firing at two different times. When you open the database it fires, but it also fires when you create the store. My quick solution to this is to create a flag that is only set when the onupgradeneeded fires (the first time through). Then, when the onsuccess event triggers, if the flag is set, the data is initialized. Otherwise not. It is very important to be aware of the flow, especially with asynchronous timing. If you try to read or write data before the database is ready, you'll get an error.
     
  2. The myDb was set in the onupgradeneeded event handler. That would mean that in subsequent times through, the myDb variable wouldn't have a value. So I added it to the onsuccess event in the main routine. That way, whichever event is triggering the onsuccess, the myDb variable is created, which is the way to reference the database in the other event handlers.
I've fixed the code above and I'll put a note in the comments and tweet the changes. I apologize for these errors. No one pointed them out, but the wit of the staircase prompted me to go back and look again, only to see that I had made a subtle but important error. Two errors, not related.

IndexedDB is a bit tricky because it is asynchronous, but I think I've wrapped my head around it and I hope you will too!

No comments:

Post a Comment