skip to content

Connecting the Dots with Web Workers

9 min read

A little while back I sat down to experiment with Web Workers and decided I would write a zero-sum game. I choose Connect 4 as the game I’d use to explore Web Workers with. It helped that I found an algorithm written by Keith Pomakis in C. First things first, I ported the algorithm to JavaScript and then started my experimentation with Web Workers. I’d like to share a couple of patterns that helped me when working with Web Workers and then explain a little bit about how I organized the code for the Connect4.js game.

Why Web Workers?

Essentially Web Workers allow us to offload heavy computations from the main thread. That means our heavy computations will not lock up the browser. In the Connect4.js game the computer will attempt to find the best move by looking ahead several moves (always assuming the other player will make the best move possible). These computations are heavy and time consuming and would normally give the dreaded long running script alert. However, with Web Workers we can just tell our worker to find the best move and let us know when it is done.

Web Worker support is currently quite limited. However, its potential power warrants investigation now, rather than later! The browsers currently supporting Web Workers are: Firefox 3.5+, Safari 4+, and Chrome. I recommend using Modernizr to detect support for modern features such as Web Workers.

How do they work?

A Web Worker is just another JavaScript file that is loaded and executed in a sandbox off the main thread. It has very limited access and can only pass strings. Although, Firefox can pass a JSON object and it will convert it to JSON internally. Lookout for this area of the API to fluctuate some. To create a new Web Worker you just create a new instance of a Worker and pass the JavaScript file it should load.

// create a new worker
var myWorker = new Worker('myworker.js');

The API for communicating with a Web Worker is super simple. There are basically two primary methods used. One for sending messages called postMessage and one for receiving messages called onmessage. Using the myWorker from above we can communicate with it like this.

// Event handler for receiving messages from the worker
myWorker.onmessage = function(event) {
    var data = event.data;
    // do something with the data
};

// method used to send a message to the worker
myWorker.postMessage('my message');

The worker actually uses the exact same methods itself for communication. So our worker.js file might look something like this.

// Event handler for receiving messages from the host
onmessage = function(event) {
    var data = event.data;
    // do something with the data
    // ...
    // send a result back to the host
    postMessage('42');
};

For more information about how Web Workers work, I invite you to take a look at the MDC page Using Web Workers.

Speak JSON

It just so happens that the browsers that support Web Workers also have native support for JSON. This is great as it allows us to communicate with the worker using JSON. And that makes it easier for our worker to know which task to execute and with what data. We can use the following JSON format to communicate with our worker.

// data to send a message to the worker
JSON.stringify({
    action: 'sum', // the action to perform
    args: [1, 2]   // the arguments to pass along
});

// data to send back the result from the worker
JSON.stringify({
    action: 'sum',  // the action performed
    returnValue: 3  // the result of the action
});

Our worker might look something like this in order to handle the multiple actions and JSON. Albeit with a rather trivial action for the example.

// available actions to run within the worker
var actions = {
    sum: function(a, b) {
        return a + b;
    }
    // other actions
};

// handle a new request from the host
onmessage = function(event) {
    var data   = JSON.parse(event.data), // parse the data
        action = data.action,            // get the requested action
        args   = data.args,              // get the arguments for the action
        result = { action: action };     // prepare the result

    // if we understand the action
    if (action in actions) {
        // execute the action and set the returnValue
        result.returnValue = actions[action].apply(this, args);
    } else {
        // otherwise set the returnValue to undefined
        result.returnValue = undefined;
    }

    // send back the results as a JSON string
    postMessage(JSON.stringify(result));
};

Logging from the Worker

Since the worker executes in a very limited environment it does not have access to the console and cannot log messages. However, we can simply add a new action called log that the host can then use to log the returnValue. Lets assume our worker sends us a log action via the following JSON.

// a message to log
JSON.stringify({
    action: 'log',
    returnValue: 'the message to log'
});

Now we can handle the log action just like any other action and just console.log the data. In the last section we saw what the worker should look like to handle the multiple actions and JSON but now lets look at what the host should look like to handle the multiple actions and JSON.

// avaialble actions to run from the worker
var actions = {
    sum: function(result) {
        // do something with the result
        // maybe publish a custom event
        // or fire a callback
    },
    log: function(message) {
        console.log(message);
    }
};

// create the worker
var myWorker = new Worker('myworker.js');

// handle a response from the worker
myWorker.onmessage = function(event) {
    var data        = JSON.parse(event.data), // parse the data
        action      = data.action,            // get the action
        returnValue = data.returnValue;       // get the returnValue

    // if we understand the action
    if (action in actions) {
        // handle the returnValue for the action
        actions[action].call(this, returnValue);
    } else {
        // throw an error? our worker isn't communicating properly
    }
};

Quick Recap

So, hopefully that wasn’t to confusing. Basically, you have a worker that you pass a message to and it should do something with the data and pass a message back to you with the result. We can utilize the JSON format to make interacting with our workers a bit more manageable. Although the example is rather trivial hopefully it provides the structure and information to make it easier to jump start your own web worker project.

Connect4.js

Lets dissect the Connect4.js game to see how it uses Web Workers. The Connect4.js game is actually composed of three different JavaScript files.

  • Connect4.js - This is the public facing API and behind the scenes uses the patterns described above to communicate with the worker.
  • Connect4Worker.js - This is the actual worker file and provides a light-weight interface to the actual game logic.
  • Connect4Game.js - This is the actual game logic and is responsible for computing moves and keeping the game state. It is loaded via importScript from within Connect4Worker.js.

Lets first look at the Connect4Worker.js. Here it is line by line with some comments.

// this method imports another script into the current context
importScripts('Connect4Game.js');

// create a local var to store the game instance
var connect4;

onmessage = function(event) {
    var data   = JSON.parse(event.data), // parse the data
        action = data.action,            // get the action
        args   = data.args,              // get the arguments for the action
        ret    = { action: action };     // prepare the result

    if (action === 'new') {
        // create a new instance
        connect4 = new Connect4Game(args[0]);
        ret.returnValue = connect4;
    }
    else if (connect4[action]) {
        // perform the action and return the results
        ret.returnValue = connect4[action].apply(connect4, args);
    }
    else {
        // undefined action
        ret.returnValue = { error: 'No method for requested action: ' + action };
    }

    // send the message back
    postMessage(JSON.stringify(ret));
};

As you can see it follows the same pattern as described earlier for communicating with the worker. One of the differences is that the actions are actually methods of the connect4 instance.

For the actual public facing interface I use a simple publish and subscribe pattern (pubsub) to handle the asynchronous behavior. So when I get a response from the worker I publish an event for that particular action. This makes it pretty easy to interact with. Using pubsub I can subscribe multiple functions to the same event. In the demo I use two events: moveend and gameend. On moveend I perform 3 actions. First I visually draw the move in the browser, then I tell the computer to make the next move, and finally if the console is available I log information about the move.

Lets take a look at a slimmed down version of the game.js file from the demo with some comments.

// create a new game and make the ai a level 5
// the callback is called once the game is all setup
new Connect4({ ai: 5 }, function(game) {
    // draw the move to the screen
    game.subscribe('moveend', drawMove);
    // play the next move
    game.subscribe('moveend', makeNextMove);
    // handle when the game is over
    game.subscribe('gameend', gameOver);

    if ('console' in window) {
        // log some information about the move
        game.subscribe('moveend', logMove);
        // log any debug info
        game.subscribe('debug', debug);
    }

    // make first move
    // autoMove makes the best possible
    // move for the specified player
    // where player 0 is 1 and 1 is 2
    game.autoMove(0);


    function drawMove(player, col, row) { /*...*/ }
    function logMove(player, col, row) { /*...*/ }
    function makeNextMove(player, col, row) { /*...*/ }
    function gameOver() { /*...*/ }
    function debug(obj) { /*...*/ }
});

In the above code when I call game.automove(0) it is sending a message (via postMessage) to the Connect4Worker.js and the Connect4Worker.js is then asking the Connect4Game.js to run the automove method. Once it is finished computing the best move it will send a message back the same way it came with the results of the move. The Connect4Worker.js always passes along the game state as well which the public facing API is then kept in sync.

More Distributed

There is nothing keeping you from using more than one worker and spawning new workers from within workers. One of the ways I was thinking that the performance could be enhanced is to use multiple workers when computing the best move. This way each potential move could be calculated in parallel if the computer had the resources. It would add some complication to the organization and code structure but might be worth the performance gains especially with a higher AI level.

The Demos

You can download and/or fork the code which is on github.