JavaScript Game Tutorial - Space Invaders Part 5 - Sprites and Polish

This is the fifth 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 4, we implemented collision detection and projectiles, making the game playable. Now it needs a User Interface and some polish.


Goals

The goals for this part are:

  • Create the HTML and CSS for the UI elements.
  • Create the Explosion and PlayerExplosion Entities.
  • Create and draw Sprites.
  • Update the renderer to handle all the UI management.


HTML

So far, the User Interface has been just a canvas and a button. We need to display the score, the remaining lives, a title screen and the high scores. UI is so dependent on your target platform that there is no one-size-fits-all solution. This is just a basic, static desktop layout.

The User Interface is made entirely of normal DOM elements styled to blend in around the canvas. The Renderer is responsible for hiding, showing and updating each of these elements.

User Interface Elements
Element Description
Current Score Text at top left of the screen. Visible at all times.
Player Ships Remaining Images at bottom left of the screen. Visible at all times.
Title Text Text that overlays the canvas. Visible only when the game hasn't been started.
High Scores A table that overlays the canvas. Visible only when the game has ended.
Start Button A button that overlays the canvas. Visible when the game hasn't been started or when the game has ended.
<link href="https://fonts.googleapis.com/css?family=VT323" rel="stylesheet">
<link href="style.css" rel="stylesheet">

<div class="wrapper">
    <div class="container">
        <div class="widebar" id="score">Score</div>
        <div class="canvas-container">
            <div id="title">
                Invaders<br><br><br>
                How To Play<br>
                Move: Arrow Keys / Tap Sides<br>
                Shoot: Spacebar / Tap Middle
            </div>
            <div id="highscores">
                <table id="scoretable">
                    <tr><th class="colheader" colspan="2">High Scores</th></tr>
                    <tr><td class="col1"> 1.</td> <td class="col2" id="score0">0</td></tr>
                    <tr><td class="col1"> 2.</td> <td class="col2" id="score1">0</td></tr>
                    <tr><td class="col1"> 3.</td> <td class="col2" id="score2">0</td></tr>
                    <tr><td class="col1"> 4.</td> <td class="col2" id="score3">0</td></tr>
                    <tr><td class="col1"> 5.</td> <td class="col2" id="score4">0</td></tr>
                    <tr><td class="col1"> 6.</td> <td class="col2" id="score5">0</td></tr>
                    <tr><td class="col1"> 7.</td> <td class="col2" id="score6">0</td></tr>
                    <tr><td class="col1"> 8.</td> <td class="col2" id="score7">0</td></tr>
                    <tr><td class="col1"> 9.</td> <td class="col2" id="score8">0</td></tr>
                    <tr><td class="col1">10.</td> <td class="col2" id="score9">0</td></tr>
                </table>
            </div>
            <div id="menu"><button id="start_button" onclick="game.start();">Start New Game</button></div>
            <canvas id="game-layer" width="300" height="180"></canvas>
            <script src="game.js"></script>
        </div>
        <div class="widebar" id="lives">&nbsp;</div>
    </div>
</div>

And style.css is:

* {
    box-sizing: border-box;
}

body {
    font-size: 100%;
}

.wrapper {
    display: table;
    width: 600px;
    position: relative;

    font-family: 'VT323', monospace;
    background-color: black;
    color: rgb(247, 245, 199);
}

.container {
    display: table-cell;
    vertical-align: top;
}

.canvas-container {
    position: relative;
    min-width: 320px;
}

#game-layer {
    width: 100%;
    vertical-align: top;

    z-index: 0;
}

#title {
    width:100%;
    font-size: 36px;
    text-align: center;
    line-height: 1em;

    position: absolute;
    z-index: 1;
}

.widebar {
    width:100%;

    font-size: 18px;
    line-height: 1em;
    text-align: left;
    padding-left: 5px;
}

#lives img {
    width:18px;
}

#highscores {
    font-size: 25px;
    line-height: 0.8em;

    width:100%;
    position: absolute;

    z-index: 2;

    display: none;
}

#scoretable {
    margin-left: auto;
    margin-right: auto;
    width: 20%;
    border: 0px solid white;
    border-collapse: collapse;
    background-color: rgba(0, 0, 0, 0.6);
}

#scoretable .colheader {
    text-align: right;
}

#scoretable .col1 {
    text-align: right;
    width:1%;
}

#scoretable .col2 {
    text-align: right;
    width:99%;
}

#menu {
    text-align: center;
    width: 100%;
    position: absolute;
    bottom: 1em;
    z-index: 3;
}

#start_button {
    background-color: black;
    color: rgb(247, 245, 199);
    font-family: 'VT323', monospace;
    font-size: 20px;

    padding: 2px;
    text-align: center;
    text-decoration: none;
    display: inline-block;
    border-width: 1px;
    border-color: rgb(247, 245, 199);
}


Explosion

The Explosion Entity adds some flair when an enemy is destroyed. It has a matching rank to the enemy and a duration for how long it remains. It might seem like exploding should be a part of the Enemy entity, but I find doing it this way creates cleaner code. The Explosion doesn't collide or influence the other Enemy behavior and it draws a different sprite. This is a natural part of adding a new Entity, but would require a bunch of special case code to deal with in the Enemy object.

Explosion
Property Type Description
rank Number the rank of the enemy that was destroyed
duration Number how long (in seconds) the explosion remains on screen
Function Return Type Description
update( dt ) none once the duration has been reached, destroys itself
//
// Explosion Object
//
function Explosion(position, speed, direction, rank, duration) {
    Entity.call(this, position, speed, direction);

    this.width = 13;
    this.height = 10;

    this.rank = rank;
    this.duration = duration;
}
Explosion.prototype = Object.create(Entity.prototype);

Explosion.prototype.update = function (dt) {
    Entity.prototype.update.call(this, dt);

    if( this.time > this.duration ) {
        this.hp = 0;
    }
};


Player Explosion

We also have an Explosion for when the Player is hit. The PlayerExplosion can't move, shoot, collide or react to user input, so it also makes the most sense to create a new Entity for it instead of making special cases in the Player object.

PlayerExplosion
Property Type Description
duration Number how long (in seconds) the explosion remains on screen
Function Return Type Description
update( dt ) none once the duration has been reached, destroys itself
//
// Player Explosion
//
function PlayerExplosion(position, duration) {
    Entity.call(this, position, 0, new Vector2d(0, 0));

    this.width = 20;
    this.height = 10;
    this.duration = duration;
}
PlayerExplosion.prototype = Object.create(Entity.prototype);

PlayerExplosion.prototype.update = function (dt) {
    Entity.prototype.update.call(this, dt);
    if( this.time > this.duration ) {
        this.hp = 0;
    }
};


Game

The game.update() function has to manage the explosions. An Explosion is created when an enemy dies. A PlayerExplosion is created when the Player dies and then a Player is created once the PlayerExplosion dies.

// In game.update(time)
if( e.hp <= 0 ) {
    removeEntities.push(e);

    if( e instanceof Enemy ) {
        _score += e.rank + 1;
        this.addEntity( new Explosion(e.position, e.speed, e.direction, e.rank, 5/60));
    }

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

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


Sprite

For the sake of demonstration, each sprite is going to be only one color and two frames of animation. Furthermore, the color is going to be set at run time. This is a way to demonstrate that you aren't limited to just loading images. You can freely manipulate the pixels as much as you want. A more complicated version of this would allow you to use color palettes much like early game consoles used to do. So you could create new characters by just changing the color palette of an existing sprite. Of course, if you want to just load images directly, you can simply remove the image editing code.

I used the free web app Piskel to create these sprites:

Sprite Image
Player Player
Player Explosion PlayerExplosion
Enemy Rank 0 Enemy Rank 0
Enemy Rank 1 Enemy Rank 1
Enemy Rank 2 Enemy Rank 2
Enemy Rank 3 Enemy Rank 3
Enemy Rank 4 Enemy Rank 4
Explosion Explosion

The sprite can have as many frames as you want, all spaced horizontally in the image file. The update function changes the frame based on the framerate.

If you have a lot of sprites, the best practice is to put them all into one image to download from the server, saving you a lot of network requests. You would load the master image and then split it apart into individual rows to use to load into each sprite object.

Sprite
Property Type Description
imgPath String URL to the image to load
image Image image to draw from (contains all frames)
currentFrame Number the current frame offset
frames Number the number of frames in the animation
frameRate Number the number of frames to cycle through per second
r Number red component
g Number green component
b Number blue component
Function Return Type Description
update( dt ) none increment currentFrame based on frameRate

The spriteImage.onload function simply colors all the pixels as the sprite rgb values. The shape of the sprite is determined by the alpha channel.

//
// Sprite Object
//
function Sprite(imgPath, frames, frameRate, r, g, b) {
    var spriteImage = new Image();
    var image = new Image();

    spriteImage.onload = function () {
        var spriteCanvas = document.createElement("canvas");
        var spriteContext = spriteCanvas.getContext('2d');

        spriteCanvas.width = spriteImage.width;
        spriteCanvas.height = spriteImage.height;

        spriteContext.drawImage(spriteImage,
                                0, 0, spriteImage.width, spriteImage.height,
                                0, 0, spriteCanvas.width, spriteCanvas.height);

        var sourceData = spriteContext.getImageData(0, 0, spriteImage.width, spriteImage.height);

        var data = sourceData.data;
        for (var i=0; i<data.length; i += 4) {
            data[i]  = r;
            data[i+1]= g;
            data[i+2]= b;
            // Leave the alpha channel alone
        }
        spriteContext.putImageData(sourceData, 0, 0);

        image.src = spriteCanvas.toDataURL('image/png');
    };

    spriteImage.src = imgPath;

    this.frames = frames;
    this.frameRate = frameRate;
    this.timer = 0;
    this.currentFrame = 0;
    this.image = image;
}

Sprite.prototype.update = function (dt) {
    this.timer += dt;
    if( this.timer > 1/this.frameRate ) {
        this.timer = 0;

        this.currentFrame = (this.currentFrame+1)%this.frames;
    }
};


Renderer

The renderer has to load the sprites, animate them, draw them and update the UI elements.

Renderer
Function Return Type Description
_drawSprite(sprite, entity) none draw the sprite on its current frame at the entity position
_updateUI() none update all the UI elements
var _playerSprite = new Sprite("/assets/Invader/player.png",
                                1, 1, 255, 255, 0);
var _playerExplosionSprite = new Sprite("/assets/Invader/player_explosion.png",
                                2, 4, 255, 255, 0);

var _enemySprites = [new Sprite("/assets/Invader/enemy0.png",
                                 2, 2, 150, 7, 7),
                     new Sprite("/assets/Invader/enemy1.png",
                                2, 2, 150, 89, 7),
                     new Sprite("/assets/Invader/enemy2.png",
                                2, 2, 56, 150, 7),
                     new Sprite("/assets/Invader/enemy3.png",
                                2, 2, 7, 150, 122),
                     new Sprite("/assets/Invader/enemy4.png",
                                2, 2, 46, 7, 150)];

var _explosionSprites = [new Sprite("/assets/Invader/explosion.png",
                                    1, 1, 150, 7, 7),
                         new Sprite("/assets/Invader/explosion.png",
                                    1, 1, 150, 89, 7),
                         new Sprite("/assets/Invader/explosion.png",
                                    1, 1, 56, 150, 7),
                         new Sprite("/assets/Invader/explosion.png",
                                    1, 1, 7, 150, 122),
                         new Sprite("/assets/Invader/explosion.png",
                                    1, 1, 46, 7, 150)];

var _sprites = [].concat(_playerSprite, _playerExplosionSprite, _enemySprites, _explosionSprites);

function _drawSprite(sprite, entity) {
    _context.drawImage(sprite.image,
                       (sprite.image.width/sprite.frames)*sprite.currentFrame,
                       0,
                       sprite.image.width/sprite.frames,
                       sprite.image.height,
                       entity.position.x-entity.width/2,
                       entity.position.y-entity.height/2,
                       entity.width, entity.height);
}

Updating DOM elements is a big performance hit, so you only update them if the value has actually changed. This would seem like it would happen automatically, but it doesn't (at least in the browsers that I've tried).

var _previousLives = 0;

function _updateUI() {
    var scoreElement = document.getElementById("score");
    var highScoresElement = document.getElementById("highscores");
    var menuElement = document.getElementById("menu");
    var titleElement = document.getElementById("title");
    var livesElement = document.getElementById("lives");

    // Update Score
    var scoreText = "Score " + Math.round(game.score());
    if( scoreElement.innerHTML != scoreText ) {
        scoreElement.innerHTML = scoreText;
    }

    // Update Player Lives
    if( _previousLives !== game.livesRemaining() ) {
        _previousLives = game.livesRemaining();

        while( livesElement.hasChildNodes() ) {
            livesElement.removeChild(livesElement.firstChild);
        }

        livesElement.innerHTML = "&nbsp;";

        // Add an image for each life
        for(i=0; i<game.livesRemaining(); i++) {
            var img = document.createElement("img");
            img.src = _playerSprite.image.src;
            img.style.marginRight = "5px";

            livesElement.appendChild(img);
        }
    }

    if( game.gameOver() ) {
        // Update High Scores
        var scores = game.highScores();
        for( i=0; i<scores.length; i++) {
            var elem = document.getElementById("score"+i);
            elem.innerHTML = scores[i];
        }

        highScoresElement.style.display = "block";
        menuElement.style.display = "block";
        titleElement.style.display = "none";
    }
    else {
        highScoresElement.style.display = "none";
        menuElement.style.display = "none";
        titleElement.style.display = "none";
    }
}

The renderer is updated to allow a scalable canvas. In a responsive layout, we would change assets depending on the scaleFactor. Animate the sprites by calling update(dt). Add a visible floor to make a clear separation between the game field and the UI underneath it. Finally, call _updateUI() to keep everything up to date.

function _render(dt) {
    var i,
        entity,
        entities = game.entities();

    // Calculate ScaleFactor
    _scaleFactor = _canvas.clientWidth / game.gameFieldRect().width;
    _scaleFactor = Math.max(1, Math.min(2, _scaleFactor));
    _canvas.width = game.gameFieldRect().width * _scaleFactor;
    _canvas.height = game.gameFieldRect().height * _scaleFactor;
    _context.scale(_scaleFactor, _scaleFactor);

    // Update the Sprites
    for( i=_sprites.length-1; i>=0; i--) {
        _sprites[i].update(dt);
    }

    // Draw Background
    _context.fillStyle = "black";
    _context.fillRect(0, 0, _canvas.width, _canvas.height);

    // Draw Entities
    for( i=entities.length-1; i>=0; i-- ) {
        entity = entities[i];

        if( entity instanceof Enemy ) {
            _drawSprite(_enemySprites[entity.rank], entity);
        }
        else if( entity instanceof Player ) {
            _drawSprite(_playerSprite, entity);
        }
        else if( entity instanceof PlayerExplosion ) {
            _drawSprite(_playerExplosionSprite, entity);
        }
        else if( entity instanceof Explosion ) {
            _drawSprite(_explosionSprites[entity.rank], entity);
        }
        else if( entity instanceof Projectile ) {
            _drawRectangle(_projectileColors[entity.type], entity);
        }
    }

    // Draw Floor
    _context.strokeStyle = "#816d1a";
    _context.moveTo(0, game.gameFieldRect().height);
    _context.lineTo(game.gameFieldRect().width, game.gameFieldRect().height);
    _context.stroke();

    // Update UI
    _updateUI();
}


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

At this point, we have a perfectly functional, though basic, game. You can add on to this engine as much or as little as you need. 3D graphics, audio, far more complicated collision and physics are just a few of the obvious additions. In the upcoming parts, we will look at adding a few of these features.

Thank you for taking the time to go through this series and please do let me know if you create anything at all based on this. I would love to see it.

In the next parts:

  • Part 6. Optimize the memory usage to ensure a stable frame rate.
  • Part 7. Switch to a 3D Renderer.
  • Part 8. Add an Event System and Audio.

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

Question or Comment?