JavaScript Game Tutorial - Space Invaders Part 8 - Events and Audio

This is the eighth part in a series about creating a Space Invaders clone in JavaScript. It is highly recommended to start from the beginning as each part builds directly upon the previous.

Space Invaders Tutorial Series
Part 0 Beginner JavaScript Game Tutorial For Professional Use
Part 1 Math Classes and Game Entity Structure
Part 2 User Input
Part 3 Enemy Behavior
Part 4 Collision Detection and Projectiles
Part 5 Sprites and User Interface
Part 6 Optimization
Part 7 3D Renderer
Part 8 Events and Audio


Introduction

In Part 7, we created a 3D renderer to demonstrate how easy it was to swap out a major feature with another implementation. Similarly, now we are going to add an audio system.

This, however, will take some modification to the rest of the codebase as it does require a supporting system. We will have to create an event system that will be used to decide when a sound will be played. This type of system is incredibly useful in all sorts of applications, so it's good practice to develop a simple version.

We will be using the WebAudio API for all the sounds. If you are not already familiar with this, I have written an introductory tutorial that covers all the basics that we will need.

In term of actual audio files, here are the sound effects we will be using:

We also need to include two script files:

We include those in our HTML file and use the variables they contain in our code.

<script src="Full_1.js"></script>
<script src="impulseResponse.js"></script>


Goals

The goals for this part are:

  • Create an inter-object Notification system.
  • Integrate Notifications into the codebase.
  • Create the AudioRenderer.


Notifications

We need to create an event system, but in order to avoid confusion with the JavaScript DOM-based event system, we won't use the same terminology. Instead of Events, I call these Notifications. Anyone with a macOS or iOS background will immediately be familiar with this. Notifications are sent through the NotificationCenter, which is a central object that all others can register with to observe notifications from any other object.

A Notification is very simple. It consists of a unique name and an object that contains any pertinent information. We call this the userInfo object.

Notification
Property Type Description
name String A unique name for the notification
userInfo Object An object containing any information for use by the receiver of the notification.

The NotificationCenter is a little more complicated. Any object can register with the NotificationCenter to get a callback when a Notification is posted. The NotificationCenter maintains a list of all objects that have registered to receive specific notifications. When a Notification is posted, the NotificationCenter checks if any objects have registered for it and then invokes the callback.

Notification Center
Function Returns Description
addObserver(observer, name, callback) none The callback will be invoked for the observer when the notification with the given name is posted.
removerObserver(observer, name) none The observer will be removed from receiving the notifications of the given name.
post(name, userInfo) none Post a notification with the given name and userInfo to all registered observers of that notification.


//
// Notification Center
//
var notificationCenter = (function () {

    // The list of all the observers.
    var _observers = [];

    // Add the observer, notification name and callback
    // to the observers list. We only allow an observer to
    // register once for a notification.
    function _addObserver( observer, name, callback ) {
        for( var i = _observers.length-1; i>=0; i--) {
            var o = _observers[i];
            if( o.observer === observer && o.name === name ) {
                console.log("Error: Observer already exists for notification: " + name);
                return;
            }
        }

        _observers.push( {observer: observer,
                              name: name,
                          callback: callback});
    }

    // Remove from the observer list the index with
    // the observer object and notification name.
    function _removeObserver( observer, name ) {
        for( var i = _observers.length-1; i>=0; i--) {
            var o = _observers[i];
            if( o.observer === observer && o.name === name ) {
                mutableRemoveIndex(_observers, i);
                break;
            }
        }
    }

    // Post the notification with the given name and userInfo.
    // Iterate through the observers list and invoke the callback
    // for all observers that have registered for the notification name.
    function _post( name, userInfo ) {
        var note = {name: name, userInfo: userInfo};

        for( var i = _observers.length-1; i>=0; i--) {
            var o = _observers[i];
            if( o.name === name ) {
                o.callback(note);
            }
        }
    }

    return {
        addObserver: _addObserver,
        removeObserver: _removeObserver,
        post: _post
    };
})();

This turns out to be a very convenient system for inter-object communication. It helps keep code very modular between major systems, by never having to call into one another, but just handling changes through notifications. This clearly isn't the most efficient or robust implementation, but for small uses it works fine. Note that the mutableRemoveIndex() function was written in Part 6 of this tutorial series.

The notifications we need to create are when the Player fires, the Player is hit, an Enemy is hit and the enemy speed changes. These will be called "PlayerDidFire", "PlayerWasHit", "EnemyWasHit" and "EnemySpeedDidChange."


Player

Update the Player.fire() function to post the "PlayerDidFire" notification. In a more complicated game, you might include information about the weapon the player was using in the userInfo object.

Player.prototype.fire = function () {
    var playerProjectileCount = 0;

    var projectiles = game.projectiles();
    for(var i=projectiles.length-1; i>=0; i--) {
        if(projectiles[i].type === "player") {
            playerProjectileCount++;
        }
    }

    if( playerProjectileCount === 0 ) {

        var proj = game.projectilePool().take();
        proj.position.set( this.position.x, this.position.y );
        proj.speed = 180;
        proj.direction.set( 0, -1 );
        proj.type = "player";

        game.addEntity(proj);

        // Post a notification that we fired the weapon.
        notificationCenter.post( "PlayerDidFire", {player: this} );
    }
}


Game

The notifications "PlayerWasHit", "EnemyWasHit" and "EnemySpeedDidChange" are all handled in the game.update() function.

function _update(time) {
    ...

    // Delete dead objects.
    var removeEntities = [];
    for( i=_entities.length-1; i>=0; i-- ) {
        var e = _entities[i];

        if( e.hp <= 0 ) {
            removeEntities.push(e);

            if( e instanceof Enemy ) {
                _score += e.rank + 1;

                var exp = _explosionPool.take();
                exp.position.set(e.position.x, e.position.y);
                exp.speed = e.speed;
                exp.direction.set(e.direction.x, e.direction.y);
                exp.rank = e.rank;
                exp.duration = 5/60;

                this.addEntity(exp);

                // Post the "EnemyWasHit" notification.
                notificationCenter.post("EnemyWasHit", {enemy: e});
            }

            else if( e instanceof Player ) {
                _livesRemaining--;
                this.addEntity( new PlayerExplosion(e.position, 2) );

                // Post the "PlayerWasHit" notification.
                notificationCenter.post("PlayerWasHit", {player: e});
            }

            else if( e instanceof PlayerExplosion ) {
                this.addEntity( new Player( new Vector2d(100, 175), 90, new Vector2d(0, 0) ));
            }
        }
    }

    _removeEntities(removeEntities);

    // Update Enemy Speed
    var speed = _enemySpeed + (1.5*_enemySpeed*(1-(_enemies.length/50)));
    for( i=_enemies.length-1; i>=0; i-- ) {
        _enemies[i].speed = speed;
    }

    // Post the "EnemySpeedDidChange" notification.
    notificationCenter.post("EnemySpeedDidChange", {speed: speed});

    ...
}

Now that the notifications are being posted, we can create an AudioRenderer that will register for these notifications and play sounds when they occur.


Audio Renderer

The AudioRenderer object takes care of all the sound in the game using the WebAudio API. It plays sound effects when the notifications are posted and plays very simple music that speeds up with the speed of the enemies on screen.

AudioRenderer
Property Type Description
_enabled Boolean Whether or not the AudioRenderer will play any sounds.
_audioCtx AudioContext The WebAudio API audio context.
_musicGainNode GainNode The gain control for the music.
_sfxGainNode GainNode The gain control for the sound effects.
_reverbNode ConvolverNode The reverb effect for the music.
_musicFreqs Array The note frequencies for the music.
_musicFreqsIndex Number The current frequency of the music being played.
_musicBPM Number The beats per minute of the music playback.
_musicTimer Number A timer for the music playback.
_musicWave PeriodicWave The waveform being used for by the music OscillatorNode.
Function Returns Description
base64ToArrayBuffer( base64 ) Uint8Array Decodes a base64 encoded string to a Uint8Array.
_registerSfx( notificationName, filePath ) none Register the sound effect at the filePath to play when the notification is posted.
_init() none Initializes all variables and registers for the notifications.
update( dt ) none Updates the music playback.
enabled() Boolean Returns whether or not the AudioRenderer is enabled.
setEnabled( enabled ) none Sets whether or not the AudioRenderer is enabled.


//
// AudioRenderer
//
var audioRenderer = (function() {

    // Enabled
    var _enabled = true;

    // Web Audio
    var _audioCtx;
    var _musicGainNode;
    var _sfxGainNode;
    var _reverbNode;

    // Music
    var _musicFreqs;
    var _musicFreqsIndex;
    var _musicBPM;
    var _musicTimer;
    var _musicWave;

We need this base64ToArrayBuffer function so we can read the impulse response data for the reverb effect.

    // Convert a base64 string to Uint8Array buffer.
    function base64ToArrayBuffer(base64) {
        var binaryString = window.atob(base64);
        var len = binaryString.length;
        var bytes = new Uint8Array(len);
        for (var i = 0; i < len; i++)        {
            bytes[i] = binaryString.charCodeAt(i);
        }
        return bytes.buffer;
    }

Next, we handle the sound effects. First, it loads the sound file from the server with an XMLHttpRequest. When the file finishes loading, we take the XHR response array buffer and decode it as audio data using the audio context. On successful decoding, we register with the notification center with the notification name and a function that plays the audio.

This is a very basic setup. It only allows for a notification to play a single sound, which is very limiting. Also, if multiple notifications want to play the same sound, it will be loaded multiple times from the server. Nonetheless, for a simple game, it works fine.

    // Load a sound file to play on the notification.
    function _registerSfx(notificationName, filePath) {
        var xhr = new XMLHttpRequest();
        xhr.open('GET', encodeURI(filePath), true);
        xhr.responseType = 'arraybuffer';

        xhr.onerror = function() {
            console.log('Error loading from server: ' + filePath);
        };

        // On successful load,
        //  1. decode the audio data
        //  2. register the notification to the play the sound
        xhr.onload = function() {

            _audioCtx.decodeAudioData(xhr.response,

                // Success
                function(audioBuffer) {

                    notificationCenter.addObserver(this, notificationName, function( note ) {
                        if( !_enabled ) return;

                        var bufferSource = _audioCtx.createBufferSource();
                        bufferSource.buffer = audioBuffer;
                        bufferSource.connect(_sfxGainNode).connect(_audioCtx.destination);
                        bufferSource.start();
                    });
                },

                // Error
                function(e) {
                    console.log("Error decoding audio data: " + e.err);
                });
        };

        xhr.send();
    }

In the init() function, we initialize the WebAudio nodes as well as register the sound effects and register a function to update the music speed. The impulseResponse and full1Wave variables come from the included scripts above.

    // Initialize
    function _init() {
        // Web Audio
        var AudioContext = window.AudioContext || window.webkitAudioContext;
        _audioCtx = new AudioContext();

        // Music volume
        _musicGainNode = _audioCtx.createGain();
        _musicGainNode.gain.value = 0.6;

        // Sound Effects volume
        _sfxGainNode = _audioCtx.createGain();
        _sfxGainNode.gain.value = 0.15;

        // Reverb for the music
        _reverbNode = _audioCtx.createConvolver();
        _audioCtx.decodeAudioData(base64ToArrayBuffer(impulseResponse),
            function(buffer) {
                _reverbNode.buffer = buffer;
            },
            function(e) {
                console.log("Error decoding audio data: " + e.err);
            });

        // Music
        _musicFreqs = [92.499, 87.307, 82.407, 77.782];
        _musicBPM = 60;
        _musicTimer = 0.5;
        _musicFreqsIndex = 0;
        _musicWave = _audioCtx.createPeriodicWave(full1Wave.real, full1Wave.imag);

        // Sound Effects
        _registerSfx("PlayerDidFire", "assets/PlayerDidFire.wav");
        _registerSfx("EnemyWasHit", "assets/EnemyWasHit.wav");
        _registerSfx("PlayerWasHit", "assets/PlayerWasHit.wav");

        // EnemySpeedDidChange simply updates the music beats per minute.
        notificationCenter.addObserver(this, "EnemySpeedDidChange", function( note ) {
            _musicBPM = note.userInfo.speed+65;
        });
    }

The update() function is purely concerned with handling the music playback. All this system does is loop through the frequency array and play the next frequency on every beat, as determined by the _musicBPM variable.

    // Update the music playback.
    function _update(dt) {
        if( !_enabled ) return;

        _musicTimer += dt;

        // We play the next note on every beat.
        if( _musicTimer > 60/_musicBPM ) {
            _musicTimer -= (60/_musicBPM);

            // The duration of the note
            var duration = 0.9*(60/_musicBPM);

            // We cycle through the frequencies endlessly
            var freq = _musicFreqs[_musicFreqsIndex];
            _musicFreqsIndex = (_musicFreqsIndex+1) % _musicFreqs.length;

            // Each note decays over its duration
            _musicGainNode.gain.setValueAtTime(0.5, _audioCtx.currentTime);
            _musicGainNode.gain.linearRampToValueAtTime(0, _audioCtx.currentTime+duration);

            // Play the note.
            var osc = _audioCtx.createOscillator();
            osc.setPeriodicWave(_musicWave);
            osc.frequency.value = freq;
            osc.connect(_musicGainNode).connect(_reverbNode).connect(_audioCtx.destination);
            osc.start();
            osc.stop(_audioCtx.currentTime + duration);
        }
    }

Finally, we run the init() function and expose the public functions.

    // Initialize
    _init();

    return {
        update: _update,
        enabled: function () { return _enabled; },
        setEnabled: function( e ) { _enabled = e; }
    };
})();


Game

Now, we just need to call audioRenderer.update() in the game.update() function. We will insert this right after the graphics renderer is updated.

function _update(time) {
    ...

    // Render the frame
    // renderer.render(dt);
    renderer3d.render(dt);
    audioRenderer.update(dt);

    ...
}


User Interface Update

Any application that plays sound, especially one running in a web page, needs to have an easy way to mute the sound. We are going to add an audio toggle button to the bottom right of the screen. The easiest way to accomplish this is with a specially styled checkbox that simply toggles the audioRenderer's enabled flag.

We need two icons for this:

Mute Icon Speaker Icon

In the same part of the HTML where we draw the remaining lives, we insert the checkbox. When clicked, it calls the audioRenderer.setEnabled() function with the state of the checkbox.

<div class="widebar">
    <div style="float:left;" id="lives">&nbsp;</div>

    <input checked
           type="checkbox"
           id="mute_button"
           onclick="audioRenderer.setEnabled(this.checked);"/>
    <label for="mute_button"></label>

    <div style="clear:both;"></div>
</div>

We style it with this CSS. We actually hide the checkbox itself and just use the label to display the two icons.

input[type=checkbox]{
    display:none;
}
input[type=checkbox] + label:before{
    float:right;
    width: 30px;
    content: url("Mute_Icon.svg");
}
body input[type=checkbox]:checked + label:before{
    content: url("Speaker_Icon.svg");
}

With that, we have a working audio system.

Score
Invaders


How To Play
Move: Arrow Keys / Tap Sides
Shoot: Spacebar / Tap Middle
High Scores
1. 0
2. 0
3. 0
4. 0
5. 0
6. 0
7. 0
8. 0
9. 0
10. 0
 


Conclusion

Adding sound to the game engine really completes things. Now every element is in place, if in a basic form, to build out a full featured game.

You can use the notification center to create much more complex entity and system interaction. An AI system could use notifications to coordinate behavior. The graphics renderer could use the same notifications to display special effects on the screen. It gives you a lot of flexibility, while maintaining the modularity that keeps the code manageable.

This concludes the Space Invaders tutorial series. I hope you have found it useful. Please let me know if you make your own game based off of this, I'd love to see it.

I have packaged the code for the full tutorial series for anyone interested in downloading it.

Question or Comment?