Simple Slot machine game using HTML5 Part 2: Audio

UPDATE: See also Simple Slot machine game using HTML5 Part 3: Loading.

In part 1 I presented simple slots machine purely in HTML5. This part extends the basic implementation with audio support. The game itself is simple slot machine that has only one winning line and we add effects for roll start, reel stop and win/loss.

Slots with audio

Try audio enabled game here. Note that loading time is significantly longer in audio enabled version. Debug text under button tells what audio system game uses for your browser.
Original non audio version from part 1 is here.

How to support Audio

Web game can implement audio in 3 main ways.

1. Flash player audio. (e.g. use SoundManager2 library)
2. HTML5 Audio (Buzz is easy way to use it)
3. Web Audio API. (See HTML5 Rocks for tutorial)

Flash audio is pretty much deprecated now, as only older browsers will still need it that most of don’t support HTML5 canvas anyway.
HTML5 Audio works very well for desktop browsers, but has only nominal support for mobile (Android & iOS). It’s generally too moody for tablets or mobile.
Web Audio API is supported only by latest browsers, but it works reliably e.g. in Safari iOS 6.0.

Web Audio API has two implementations in wild, the WebKit (iOS, Safari, Chrome) and the Standard (latest Firefox). Fortunately differences are small.

The libraries listed above simplify implementation a lot, but it’s easier to understand how these technologies work with simple examples. So I implemented both methods 2 and 3 from scratch.

Some caveats with audio.

  • Game initial loading time will increase. Audio files can be pretty large and they must be usually preloaded so they can be played on game start
  • iOS (iPad/iPhone) does not allow autoplay for audio. Audio must be enabled by playing some sound in click event handler.

Implementation

Initialization function accepts array of objects that have required audio file name in id property and callback that is called after audio has been initialized and loaded.
First code checks if mp3 or ogg is supported. Firefox requires .ogg and it’s easy to convert at least in OS/X or Linux with ffmpeg. Exact command line depends little on ffmpeg version.

$ ffmpeg -i win.mp3 -strict experimental -acodec vorbis -ac 2 win.ogg

When format is known, the code checks wether to use Web Audio API or normal HTML5 Audio.

function initAudio( audios, callback ) {

    var format = 'mp3';
    var elem = document.createElement('audio');
    if ( elem ) {
        // Check if we can play mp3, if not then fall back to ogg
        if( !elem.canPlayType( 'audio/mpeg;' ) && elem.canPlayType('audio/ogg;')) format = 'ogg';
    }

    var AudioContext = window.webkitAudioContext || window.mozAudioContext || window.MSAudioContext || window.AudioContext;

    if ( AudioContext ) {
        // Browser supports webaudio
        return _initWebAudio( AudioContext, format, audios, callback );
    } else if ( elem ) {
        // HTML5 Audio
        return _initHTML5Audio(format, audios, callback);
    } else {
        // audio not supported
        audios.forEach(function(item) {
            item.play = function() {}; // dummy play
        });
        callback();
    }
}

Both initialization functions attempt to load the desired format of needed audio files and sets play function in objects that is used to play the effect. If audio initialization fails, this play function is set to dummy empty function.

HTML5 Audio initialization function creates Audio objects and sets src to point to the audio file. Downloading is handled automagically by the browser.

function _initHTML5Audio( format, audios, callback ) {

    function _preload( asset ) {
        asset.audio = new Audio( 'audio/' + asset.id + '.' + format);
        asset.audio.preload = 'auto';
        asset.audio.addEventListener("loadeddata", function() {
            // Loaded ok, set play function in object and set default volume
            asset.play = function() {
                asset.audio.play();
            };
            asset.audio.volume = 0.6;
        }, false);

        asset.audio.addEventListener("error", function(err) {
            // Failed to load, set dummy play function
            asset.play = function() {}; // dummy
        }, false);

    }
    audios.forEach(function(asset) {
        _preload( asset );
    });
...

Web Audio initialization is bit more complicated, it needs to download the audio with XHR requests.

function _initWebAudio( AudioContext, format, audios, callback ) {

    var context = new AudioContext();

    function _preload( asset ) {
        var request = new XMLHttpRequest();
        request.open('GET',  'audio/' + asset.id + '.' + format, true);
        request.responseType = 'arraybuffer';

        request.onload = function() {
            context.decodeAudioData(request.response, function(buffer) {

                asset.play = function() {
                    var source = context.createBufferSource(); // creates a sound source
                    source.buffer = buffer;                    // tell the source which sound to play
                    source.connect(context.destination);       // connect the source to the context's destination (the speakers)
                    // support both webkitAudioContext or standard AudioContext
                    source.noteOn ? source.noteOn(0) : source.start(0);
                };
                // default volume
                // support both webkitAudioContext or standard AudioContext
                asset.gain = context.createGain ? context.createGain() : context.createGainNode();
                asset.gain.connect(context.destination);
                asset.gain.gain.value = 0.5;


            }, function(err) {
                asset.play = function() {};
            });
        };
        request.onerror = function(err) {
            asset.play = function() {};
        };
        request.send();
    }

    audios.forEach(function(asset) {
        _preload( asset );
    });

}

NOTE: Chrome supports XMLHttpRequest only when loading pages over HTTP. If you load the HTML file locally you’ll see erros like this in the error console:
XMLHttpRequest cannot load file:///Users/teemuikonen/work/blog/slot2/audio/roll.mp3. Cross origin requests are only supported for HTTP.

After audio has initialized, the game can play any effect simply by calling play function for the effect. If audio initialization or loading failed, the play is simply a dummy function.

$('#play').click(function(e) {
    // start game on play button click
    $('h1').text('Rolling!');
    game.audios[0].play(); // Play start audio
    ...

Code is available in Github.

Continue to Simple Slot machine game using HTML5 Part 3: Loading.

Ad-hoc static file web server for development use with Node.js

There is often need to serve local files through web server so you can access them with browser at http://localhost:8000/ for development and debugging.

Python has good tool, SimpleHTTPServer that can run standalone simply by command python -m SimpleHTTPServer in the directory you want to serve.

Node.js has something similar, http-server that works essentially in the same way.

That said, Today I thought that what internet needs is yet another example of http server with Node.js. Instead of the tools above I tend to use my own one-file implementation as it’s easy to expand and modify for testing purposes, for example to add some mock up Ajax endpoints.
The code also demonstrates some common node.js programming patterns so learning programmers might find it useful.

Code

Server is based on express framework and uses send and async modules to serve index.html and directory listings.

Minimum viable static file server is only few lines in express.

var express = require('express'),
    path = require('path');

var mainapp = express();
mainapp.use(express.static( process.cwd() ));
mainapp.listen(8000);

In case file is not found, we need fallback handler for checking the index.html or if that does not exists then build directory listing. Note that this listens only HTTP GET requests, not POST or HEADs.

mainapp.get('*', function(req, res) {
   var pathname = url.parse(req.url).pathname;
   pathname = path.join(dir, pathname);

    fs.stat(pathname, function(err, stat) {
        // Check if path is directory
        if ( !stat || !stat.isDirectory() ) return res.send(404);

        // check for index.html
        var indexpath = path.join(pathname, 'index.html');
        fs.stat(indexpath, function(err, stat) {
           if ( stat && stat.isFile() ) {
               // index.html was found, serve that
               send(res, indexpath)
                   .pipe(res);
               return;

           } else {
               // No index.html found, build directory listing
               fs.readdir(pathname, function(err, list) {
                  if ( err ) return res.send(404);
                  return directoryHTML( res, req.url, pathname, list );
               });
           }
        });
    });
});

There is to default 404 handler, as express does this automatically.

Function that builds the HTML page from directory listing is surprisingly messy, as it’s not easy to do directory iteration with asynchronous node.js fs api. Directory listing use same HTML layout as python’s SimpleHTTPServer.

function directoryHTML( res, urldir, pathname, list ) {
    var ulist = [];

    function sendHTML( list ) {
        res.setHeader('Content-Type', 'text/html');
        res.send('<!DOCTYPE html>' +
            '<html>\n' +
            '<title>Directory listing for '+urldir+'</title>\n' +
            '<body>\n' +
            '<h2>Directory listing for '+urldir+'</h2>\n' +
            '<hr><ul>\n' +
            list.join('\n') +
            '</ul><hr>\n' +
            '</body>\n' +
            '</html>');
    }

    if ( !list.length ) {
        // Nothing to resolve
        return sendHTML( ulist );
    }

    // Check for each file if it's a directory or a file
    var q = async.queue(function(item, cb) {
        fs.stat(path.join(pathname, item), function(err, stat) {
           if ( !stat ) cb();
           if ( stat.isDirectory() ) {
               ulist.push('<li><a href="'+item+'/">'+item+'/</a></li>')
           } else {
               ulist.push('<li><a href="'+item+'">'+item+'</a></li>')
           }
            cb();
        });
    }, 4); // 4 parallel tasks
    // Push directory listing in workqueue
    list.forEach(function(item) {
        q.push(item);
    });
    // Set drain handler that is called when all tasks are completed
    q.drain = function() {
       sendHTML(ulist);
    };
}

Function uses async librarys queue to control the execution flow.

Running

Running directly with node. Server accepts web root directory as argument.
Download and see full code in Github.

$ git clone git://github.com/tikonen/blog.git
$ cd blog/simplehttpserver
$ node simplehttpserver.js ~/work
Listening port 8000 root dir /Users/teemuikonen/work

Server prints out request access log for debugging purposes.

127.0.0.1 - - [Sun, 2 Apr 2013 12:48:08 GMT] "GET / HTTP/1.1" 200 730 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:20.0) Gecko/20100101 Firefox/20.0"

You also install the server globally either from the checkout or from NPM repository. Install globally with

$ npm install -g simplehttpserver
$ simplehttpserver ~/work
Listening port 8000 root dir /Users/teemuikonen/work

Test server for Apple push notification and feedback integration

Here is a test server that you can use to verify your integration to Apple Push notification service and push notification feedback server. It should be good enough for testing out your application behavior and pdu format. Server helps you to get more debug info than just connecting directly to apple.

See these blog posts on details how to send push notifications

Test server runs under node.js and listens both SSL and plain ports where your application can connect. Get code from Github and generate the SSL keys (see quick howto in cert-howto.txt) and start up server

$ node server.js 
Waiting for connections in ports
Listening feedback port 2296 
Listening feedback port 2196 SSL
Listening push port 2295 
Listening push port 2195 SSL

Note that you may need to install binary module to run the server. Use npm install binary.

Successful push notification sending should look like following. The server dumps the data from your app in binary format for debugging and tries to parse it as Push PDU. Server prints out the fields so you can verify the data.

Accepted push connection 2195 1 SSL
=== RECEIVED DATA (1) ====
00000000: 0100 0000 0151 7261 1d00 206b 4628 de93  .....Qra...kF(^.
00000010: 17c8 0edd 1c79 1640 b58f dfc4 6d21 d0d2  .H.].y.@5._Dm!PR
00000020: d135 1687 239c 44d8 e30a b100 1e7b 2261  Q5..#.DXc.1..{"a
00000030: 7073 223a 7b22 616c 6572 7422 3a22 4865  ps":{"alert":"He
00000040: 6c6c 6f20 5075 7368 227d 7d              llo.Push"}}

=== PDU ====
{ command: 1,
  pduid: 1,
  expiry: Sat Apr 20 2013 17:34:21 GMT+0800 (SGT),
  tokenlength: 32,
  token: '6B4628DE9317C80EDD1C791640B58FDFC46D21D0D2D1351687239C44D8E30AB1',
  payloadlength: 30,
  payload: { aps: { alert: 'Hello Push' } } }

Test server does not validate the data, but it tries to parse JSON message in push notifications and prints error if it fails. Also if command was not set to 1, it sends back error pdu and closes connection. This should be good enough for testing. For example here I made HTTP request to the server to get some error output.

Accepted push connection 2295 1 SSL
=== RECEIVED DATA (1) ====
00000000: 4745 5420 2f20 4854 5450 2f31 2e31 0d0a  GET./.HTTP/1.1..
00000010: 5573 6572 2d41 6765 6e74 3a20 6375 726c  User-Agent:.curl
00000020: 2f37 2e32 392e 300d 0a48 6f73 743a 206c  /7.29.0..Host:.l
00000030: 6f63 616c 686f 7374 3a32 3239 350d 0a41  ocalhost:2295..A
00000040: 6363 6570 743a 202a 2f2a 0d0a 0d0a       ccept:.*/*....

=== PDU ====
{ command: 71,
  pduid: 1163141167,
  expiry: Sun Mar 01 1987 23:31:32 GMT+0800 (SGT),
  tokenlength: 20527,
  token: '312E310D0A557365722D4167656E743A206375726C2F372E32392E300D0A486F73743A206C6F63616C686F73743A323239350D0A4163636570743A202A2F2A0D0A0D0A',
  payloadlength: null,
  payload: 'ERROR: INVALID JSON PAYLOAD [SyntaxError: Unexpected end of input]' }
=== SEND ERROR: 08014554202F
Connection terminated 1

When your app connects successfully to the feedback test service, it sends back few feedback tokens and closes connection after one minute. Edit the actual tokens in the server source code.

Accepted feedback connection 2296 1
SEND: 2696A21000207518B1C2C7686D3B5DCAC8232313D5D0047CF0DC0ED5D753C017FFB64AD25B60
SEND: 2696A21100207518B1C2C7686D3B5DCAC8232313D5D0047CF0DC0ED5D753C017FFB64AD25B60
SEND: 2696A21100207518B1C2C7686D3B5DCAC8232313D5D0047CF0DC0ED5D753C017FFB64AD25B60

Source code is available in Github.