JavaScript Game Tutorial - Space Invaders Part 1 - Introduction
This is the first 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 my Beginner JavaScript Game Tutorial For Professional Use, we wrote the most basic game engine possible using the proper structure not often seen in tutorials. Now we are going to create a Space Invaders clone to see how this foundation easily builds out into a full game. This will all be written from scratch in raw JavaScript, no frameworks or libraries needed. The finished product can be seen in the Arcade section along with other games all built in this same manner.
Game Design
Making a game, even a simple one like Space Invaders, is still relatively complicated without a plan. So let's describe the game action and break it all down in pieces to figure out how to implement the whole thing.
The enemies will come down from the top of the screen in a grid and move left and right until they hit either edge at which point they will move down and move in the opposite direction away from the edge of the screen. The entire grid moves as a unit, so if one enemy hits an edge, they all move down and move in the other direction. The enemies on the bottom of each column occasionally shoot a projectile down towards the player. Each enemy has a rank which has its own graphic and points awarded when killed. The enemies closest to the player have a low rank and the ones on top have a high rank.
The player will be at the bottom of the screen and limited to moving left and right. They can shoot a projectile upwards, being limited to one shot on the screen at a time. If a player shot hits an enemy, the enemy is removed from the grid with a little explosion and the player awarded some points. All the remaining enemies speed up a little bit. When all the enemies have been killed, a new grid of enemies is created, now faster and more likely to shoot, and the player is awarded another ship.
When an enemy shot hits the player, the ship is destroyed. If there are remaining ships available, play continues with a new ship. Once all the extra ships have been destroyed, the game ends and the top 10 high scores are displayed. The high scores will be saved in the browser so the player can come back and beat their own high scores from previous sessions.
Over the course of this series, we will develop all of these features piece by piece.
Goals
The goals for this part are:
- Add needed math functions, Vector2d, Rectangle and Random number generation.
- Create an Entity class structure with Speed and Direction properties for movement.
- Update the Renderer, Physics and Game objects to use the new Entity properties.
HTML Canvas
The grid of enemies is going to be 10 columns of 5 rows. We need a bigger canvas than we've been using so far. Let's update the HTML to this:
<canvas id="game-layer" width="300" height="180" style="background:black"></canvas>
2D Vector Math
We will use vectors all over the place for movement and position of game entities in the game world. This isn't a complete implementation of all vector functions, but enough for this game.
Vector2d | ||
---|---|---|
Function | Return Type | Description |
vectorAdd( v1, v2 ) | Vector2d | adds vectors v1 and v2 together |
vectorSubtract( v1, v2 ) | Vector2d | subtract vector v2 from v1 |
vectorScalarMultiply( v1, s ) | Vector2d | multiplies the components of v1 by the scalar number s |
vectorLength( v ) | Number | returns the length of vector v |
vectorNormalize( v ) | Vector2d | returns a normalized vector v (a vector with length == 1) |
//
// Vector2d Object
//
var Vector2d = function (x, y) {
this.x = x;
this.y = y;
};
function vectorAdd(v1, v2) {
return new Vector2d(v1.x + v2.x, v1.y + v2.y);
}
function vectorSubtract(v1, v2) {
return new Vector2d(v1.x - v2.x, v1.y - v2.y);
}
function vectorScalarMultiply(v1, s) {
return new Vector2d(v1.x * s, v1.y * s);
}
function vectorLength(v) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
function vectorNormalize(v) {
var reciprocal = 1.0 / (vectorLength(v) + 1.0e-037); // Prevent division by zero.
return vectorScalarMultiply(v, reciprocal);
}
I prefer using functions instead of instance methods for math functions, because it is clearer that the vector itself isn't being modified.
var v3 = v1.add(v2); // Unclear if v1 is changed
var v3 = vectorAdd(v1, v2); // Obvious v1 is not changed
Rectangle
We are going to need rectangles to check for collisions between entities as well as defining the boundary of the game area. This is also how we will keep track of the size of the entire group of enemies to make them move as a group.
Rectangle | ||
---|---|---|
Function | Return Type | Description |
left() | Number | returns the left edge |
right() | Number | returns the right edge |
top() | Number | returns the top edge |
bottom() | Number | returns the bottom edge |
intersects( r2 ) | Boolean | returns true if the rectangle r2 intersects with this rectangle |
rectUnion( r1, r2 ) | Number | returns a rectangle that contains both rectangles r1 and r2 |
//
// Rectangle Object
//
function Rectangle (x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
Rectangle.prototype.left = function () {
return this.x;
};
Rectangle.prototype.right = function () {
return this.x + this.width;
};
Rectangle.prototype.top = function () {
return this.y;
};
Rectangle.prototype.bottom = function () {
return this.y + this.height;
};
Rectangle.prototype.intersects = function (r2) {
return this.right() >= r2.left() && this.left() <= r2.right() &&
this.top() <= r2.bottom() && this.bottom() >= r2.top();
};
function rectUnion(r1, r2) {
var x, y, width, height;
if( r1 === undefined ) {
return r2;
}
if( r2 === undefined ) {
return r1;
}
x = Math.min( r1.x, r2.x );
y = Math.min( r1.y, r2.y );
width = Math.max( r1.right(), r2.right() ) - Math.min( r1.left(), r2.left() );
height = Math.max( r1.bottom(), r2.bottom() ) - Math.min( r1.top(), r2.top() );
return new Rectangle(x, y, width, height);
}
Random Numbers
This is a simple way to get random number integers.
Function | Return Type | Description |
---|---|---|
randomInt( max ) | Number | returns an integer in the range [0, max) |
function randomInt(max) {
return Math.floor(Math.random() * Math.floor(max));
}
For example, randomInt(100) returns an integer value between 0 and 99.
Entity
Let's give proper structure to the Game Entities by creating an Entity root class with the following properties:
Entity | ||
---|---|---|
Property | Type | Description |
position | Vector2d | position (in world coordinates) of the center of the entity |
direction | Vector2d | direction of motion |
speed | Number | speed of motion |
width | Number | width of the entity |
height | Number | height of the entity |
hp | Number | hit points |
Function | Return Type | Description |
collisionRect() | Rectangle | collision rectangle centered at position (in world coordinates) |
update(dt) | none | dt (delta time) is the time difference since the last frame |
I am using speed and direction instead of velocity because we are going to be frequently changing the speed of the enemies and keeping direction and speed separate makes that very simple.
//
// Entity Object
//
function Entity(position, speed, direction) {
this.position = position;
this.speed = speed;
this.direction = direction;
this.time = 0;
this.width = 5;
this.height = 5;
this.hp = 1;
}
Entity.prototype.update = function (dt) {
this.time += dt;
};
Entity.prototype.collisionRect = function () {
return new Rectangle(this.position.x - this.width/2,
this.position.y - this.height/2,
this.width,
this.height);
};
Enemy
The Enemy class needs a rank property to distinguish different types of enemies. We will have 5 ranks of enemies, corresponding to the 5 rows of enemies on the screen. Each will have their own graphic and the points earned will be based on the rank.
Enemy | ||
---|---|---|
Property | Type | Description |
rank | Number | higher rank is worth more points |
//
// Enemy Object
//
function Enemy(position, speed, direction, rank) {
Entity.call(this, position, speed, direction);
this.width = 13;
this.height = 10;
this.rank = rank;
}
Enemy.prototype = Object.create(Entity.prototype);
Enemy.prototype.update = function (dt) {
Entity.prototype.update.call(this, dt);
if( this.collisionRect().top() <= 0 ||
this.collisionRect().bottom() >= game.gameFieldRect().bottom() ) {
this.direction.y *= -1;
}
};
Player
The Player class doesn't change too much from our base version. We will add quite a bit to the Player when we deal with user input.
//
// Player Object
//
function Player(position, speed, direction) {
Entity.call(this, position, speed, direction);
this.width = 20;
this.height = 10;
}
Player.prototype = Object.create(Entity.prototype);
Player.prototype.update = function (dt) {
Entity.prototype.update.call(this, dt);
if( this.collisionRect().top() <= 0 ||
this.collisionRect().bottom() >= game.gameFieldRect().bottom() ) {
this.direction.y *= -1;
}
};
Renderer
The Renderer has to be updated to accurately display the new Entity properties. The position is now the center of the Entity, so we update the drawing code appropriately. The Enemy rank will be shown by changing colors. Eventually we will be drawing sprites, but the code will be very similar to what is below.
Renderer | ||
---|---|---|
Property | Type | Description |
_canvas | Canvas | the drawing canvas from the DOM |
_context | CanvasRenderingContext2D | the 2D context from the canvas |
_enemyColors | Array | array of colors corresponding to the enemy ranks |
Function | Return Type | Description |
_drawRectangle( color, entity ) | none | draw a rectangle with entity.width and height, centered at entity.position, filled with the color |
_render( dt ) | none | render the scene |
//
// Renderer Object
//
var renderer = (function () {
var _canvas = document.getElementById("game-layer"),
_context = _canvas.getContext("2d"),
_enemyColors = ["rgb(150, 7, 7)",
"rgb(150, 89, 7)",
"rgb(56, 150, 7)",
"rgb(7, 150, 122)",
"rgb(46, 7, 150)"];
function _drawRectangle(color, entity) {
_context.fillStyle = color;
_context.fillRect(entity.position.x-entity.width/2,
entity.position.y-entity.height/2,
entity.width,
entity.height);
}
function _render(dt) {
_context.fillStyle = "black";
_context.fillRect(0, 0, _canvas.width, _canvas.height);
var i,
entity,
entities = game.entities();
for( i=entities.length-1; i>=0; i-- ) {
entity = entities[i];
if( entity instanceof Enemy ) {
_drawRectangle(_enemyColors[entity.rank], entity);
}
else if( entity instanceof Player ) {
_drawRectangle("rgb(255, 255, 0)", entity);
}
}
}
return {
render: _render
};
})();
Physics
The physics is the main reason for using vector math and it makes the code very clean. Later, we will add the collision detection here as well.
//
// Physics Object
//
var physics = (function () {
function _update(dt) {
var i,
e,
velocity,
entities = game.entities();
for( i=entities.length-1; i>=0; i-- ) {
e = entities[i];
velocity = vectorScalarMultiply( e.direction, e.speed );
e.position = vectorAdd( e.position, vectorScalarMultiply( velocity, dt ) );
}
}
return {
update: _update
};
})();
Game
The Game object acts, in part, as a central data repository for the other objects. All other objects query the Game object for information they need. Already the Renderer and Physics do this by querying the Game object for the entities they need to process. One of the common needs will be getting subsets of entities, so we are going to add this right now with addEntity().
Game | ||
---|---|---|
Property | Type | Description |
_entities | Array | array of all the Entity objects |
_enemies | Array | array of all the Enemy objects |
_player | Player | the Player object |
_gameFieldRect | Rectangle | rectangle that defines the boundaries of the game field |
_started | Boolean | true if the game loop has been started |
Function | Return Type | Description |
start() | none | resets all variables to start a brand new game |
_addEntity(entity) | none | add the entity and keep track of enemies |
_removeEntities(entities) | none | remove the entities from all of the arrays |
_update() | none | update the game by one time step |
//
// Game Object
//
var game = (function () {
var _entities,
_enemies,
_player,
_gameFieldRect,
_started = false;
function _start() {
_entities = [];
_enemies = [];
_gameFieldRect = new Rectangle(0, 0, 300, 180);
this.addEntity(new Player( new Vector2d(100, 175), 25, new Vector2d(0, -1)));
this.addEntity(new Enemy(new Vector2d(20, 25), 20, new Vector2d(0, 1), 0));
this.addEntity(new Enemy(new Vector2d(50, 25), 10, new Vector2d(0, 1), 1));
this.addEntity(new Enemy(new Vector2d(80, 25), 15, new Vector2d(0, 1), 2));
this.addEntity(new Enemy(new Vector2d(120, 25), 25, new Vector2d(0, 1), 3));
this.addEntity(new Enemy(new Vector2d(140, 25), 30, new Vector2d(0, 1), 4));
if( !_started ) {
window.requestAnimationFrame(this.update.bind(this));
_started = true;
}
}
function _addEntity(entity) {
_entities.push(entity);
if( entity instanceof Player ) {
_player = entity;
}
if( entity instanceof Enemy ) {
_enemies.push(entity);
}
}
function _removeEntities(entities) {
if( !entities ) return;
function isNotInEntities(item) { return !entities.includes(item); }
_entities = _entities.filter(isNotInEntities);
_enemies = _enemies.filter(isNotInEntities);
if(entities.includes(_player)) {
_player = undefined;
}
}
function _update() {
var dt = 1/60; // Fixed 60 frames per second time step
physics.update(dt);
var i;
for( i=_entities.length-1; i>=0; i-- ) {
_entities[i].update(dt);
}
renderer.render(dt);
window.requestAnimationFrame(this.update.bind(this));
}
return {
start: _start,
update: _update,
addEntity: _addEntity,
entities: function () { return _entities; },
enemies: function () { return _enemies; },
player: function () { return _player; },
gameFieldRect: function () { return _gameFieldRect; }
};
})();
Conclusion
We have created the mathematical foundation we need, given structure to the Game Entities and updated the Renderer, Physics and Game objects to use these new features. In the coming parts, we will fill in all the implementation details for each object as we create a Space Invaders clone.
Here is the road map for the rest of this series:
- Part 2. User input, both keyboard and touch, with the player moving around.
- Part 3. Enemy behavior, including descending and moving as a group.
- Part 4. Projectiles and collision detection. The game will be playable at this point.
- Part 5. Sprites and UI, handling title screens, menu, etc.
- 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 hope you have found this tutorial useful so far, and look forward to seeing what creations you build off of this base.
I have packaged the code for the full tutorial series for anyone interested in downloading it.