In this segment, I am going to go through the source code for the asteroids game I showcased on the Web Page. I’ll attempt to explain what I did to get it working and why I did what I did!
Please Note: this is not a tutorial on Javascript or the HTML Canvas. If you’re interested in such things I would suggest starting at javascript.info where a lot of good modern code is taught!
The first section of the code contains a number of classes and functions that will be essential for later parts of the game. I start with a basic Vector class.
class Vector { x = 0 y = 0 static Zero = { x: 0, y: 0 } }
This is the basic building block of the game. All positions, velocities, and more will be made using Vectors!
class CircleObject { position = Vector.Zero velocity = Vector.Zero radius = 0 }
In spite of what it may seem in the game, all the objects are actually circles! Circles are nice and simple when it comes to collision detection, so the ship, the bullets, and all the asteroids are just circles under the hood.
function circleCollision(circle1, circle2) { const dx = circle2.position.x - circle1.position.x; const dy = circle2.position.y - circle1.position.y; const distSquared = dx * dx + dy * dy; const radiiSum = circle1.radius + circle2.radius; return distSquared <= radiiSum * radiiSum; }
Here we have the Collision Detection math. Basically we take the difference between the two circles’ center position and square that value. Then we add together the radii of the circles. If the distance squared is less than the combined radii squared, then we have a hit and return true!
This is basically an implementation of the Pythagorean Theorem, adjusted slightly so as not to make use of square roots (notoriously inefficent for computers to handle!)
function randomRange(min, max) { return Math.random() * (max - min) + min; } function randomInt(min, max) { return Math.floor(randomRange(min, max)); }
These two functions will be getting a lot of use throughout the game. They are basic shorthand that allow me to get a random decimal or integer between two given numbers.
class Asteroid extends CircleObject { points = [] constructor(position, velocity, radius) { super(); this.position = position; this.velocity = velocity; this.radius = radius; this.points = this.defineShape(randomInt(5,15)); } defineShape(sides) { return new Array(sides).fill(Vector.Zero).map((_,i) => { const angle = i * Math.PI / (sides / 2); const range = this.radius / 4; const scalar = randomRange(this.radius - range, this.radius + range); return { x: this.position.x + Math.cos(angle) * scalar, y: this.position.y + Math.sin(angle) * scalar, } }); } spawnChildren(asteroids) { for (let i = 0; i < 3; i++) { asteroids.push(new Asteroid({ x: this.position.x + randomRange(-10, 10), y: this.position.y + randomRange(-10, 10), }, { x: randomRange(-1,1), y: randomRange(-1,1), }, this.radius / 3 )); } } drawCollision(c) { c.beginPath(); c.arc(this.position.x, this.position.y, this.radius, 0, 2 * Math.PI, false); c.strokeStyle = '#999'; c.stroke(); c.closePath(); } draw(c) { c.beginPath(); c.moveTo(this.points[0].x, this.points[0].y); for (let i = 1; i < this.points.length; i++) { c.lineTo(this.points[i].x, this.points[i].y); } c.lineTo(this.points[0].x, this.points[0].y); c.strokeStyle = '#999'; c.fillStyle = '#333'; c.fill(); c.stroke(); c.closePath(); } update(c) { // this.drawCollision(c); this.draw(c); this.position.x += this.velocity.x; this.position.y += this.velocity.y; for (let point of this.points) { point.x += this.velocity.x; point.y += this.velocity.y; } } }
Now this is a doozy! The Asteroid is a normal circle object except for one key difference: It is drawn as a polygon with a random number of uneven sides. To create this shape we give each Asteroid a list of points around the center position and draw lines between those points.
We also give Asteroids a special method: spawnChildren. This is called whenever an Asteroid of sufficient size is destroyed by the player. It creates three smaller asteroids that will fly in random directions away from the position of the original.
Finally we have the update method. This is called once each frame of the game loop. It calls the draw method to draw the Asteroid on the screen, then it updates the position based on the current velocity of the Asteroid.
(The drawCollision method is for testing purposes only. It draws the collision circle that the Asteroid actually uses, but we don’t want to show that to the player, so it is commented out.)
class Projectile extends CircleObject { constructor(position, velocity) { super(); this.position = position; this.velocity = velocity; this.radius = 5; } draw(c) { c.beginPath(); c.arc(this.position.x, this.position.y, this.radius, 0, Math.PI*2, false); c.closePath(); c.fillStyle = "white"; c.fill(); } update(c) { this.draw(c); this.position.x += this.velocity.x; this.position.y += this.velocity.y; } }
The Projectile class is relatively simple. It is drawn as a filled white circle, and the update method just adjusts its position according to its given velocity.
class Player extends CircleObject { rotation = 0 thrustersOn = false radius = 10 constructor(position, velocity) { super(); this.position = position; this.velocity = velocity; } reset(canvas) { this.position = { x: canvas.width / 2, y: canvas.height / 2, }; this.velocity = Vector.Zero; this.rotation = 0; } front() { return { x: this.position.x + Math.cos(this.rotation) * this.radius, y: this.position.y + Math.sin(this.rotation) * this.radius, } } shoot(projectiles, projectileSpeed) { projectiles.push(new Projectile(this.front(), { x: Math.cos(this.rotation) * projectileSpeed + this.velocity.x, y: Math.sin(this.rotation) * projectileSpeed + this.velocity.y })) } drawCollision(c) { c.beginPath(); c.arc(this.position.x, this.position.y, this.radius, 0, 2*Math.PI, false); c.strokeStyle = '#999'; c.stroke() c.closePath(); } draw(c) { c.save() // Rotation c.translate(this.position.x, this.position.y); c.rotate(this.rotation); c.translate(-this.position.x, -this.position.y); // Thruster Fire if (this.thrustersOn) { c.beginPath(); c.moveTo(this.position.x - 20, this.position.y); c.lineTo(this.position.x - 10, this.position.y - 5); c.lineTo(this.position.x - 10, this.position.y + 5); c.fillStyle = 'red'; c.fill(); c.closePath(); } // Triangle c.beginPath(); c.moveTo(this.position.x + 30, this.position.y); c.lineTo(this.position.x - 10, this.position.y - 10); c.lineTo(this.position.x - 10, this.position.y + 10); c.fillStyle = 'white'; c.fill(); c.closePath(); // Center Circle c.beginPath(); c.arc(this.position.x, this.position.y, 5, 0, Math.PI*2, false); c.fillStyle = 'rgb(192 132 252)'; c.fill(); c.closePath(); c.strokeStyle = 'white'; c.stroke(); c.restore(); } update(c) { // this.drawCollision(c); this.draw(c); this.position.x += this.velocity.x; this.position.y += this.velocity.y; } }
No surprise that the player class is a complex one. It has a number of special methods that I’ll go through one-by-one. But first the two special properties:
reset(canvas) { this.position = { x: canvas.width / 2, y: canvas.height / 2, }; this.velocity = Vector.Zero; this.rotation = 0; }
This is called when the game restarts. It just sets the player’s properties to their default state.
front() { return { x: this.position.x + Math.cos(this.rotation) * this.radius, y: this.position.y + Math.sin(this.rotation) * this.radius, } }
This simple method gives a vector pointing in the direction that the ship is facing. This will be useful for setting the ship’s velocity when the player hits the forward button.
shoot(projectiles, projectileSpeed) { projectiles.push(new Projectile(this.front(), { x: Math.cos(this.rotation) * projectileSpeed + this.velocity.x, y: Math.sin(this.rotation) * projectileSpeed + this.velocity.y })) }
Called whenever the player hits the shoot button. This spawns a new Projectile in front of the player and gives it a velocity based on the player’s direction and velocity.
I’m not going to go into the draw method for the player as drawing shapes to the canvas is a tutorial unto itself, but I will note that I draw the thruster fire coming out of the back of the ship conditionally on whether the thrustersOn property is true.
const canvas = document.querySelector('canvas#asteroids-game'); const c = canvas.getContext('2d'); canvas.width = canvas.parentElement.clientWidth; canvas.height = 300; const centerX = canvas.width / 2, centerY = canvas.height / 2; // Settings const speed = 2; const angularSpeed = 0.07; const friction = 0.97; const projectileSpeed = 2; const asteroidTiming = 3000; //Init State let frame = 0; let gameOver = false; let paused = false; let asteroidInterval; const keys = { w: { pressed: false }, a: { pressed: false }, d: { pressed: false }, space: { pressed: false }, escape: { pressed: false }, }; const player = new Player({ x: centerX, y: centerY }, Vector.Zero); let asteroids = new Array(); let projectiles = new Array();
Here we finally start setting up the game pieces. We get the canvas element on the webpage and get the drawingContext, then set the canvas dimensions.
Next is a set of constants we call Settings that determine how fast things move on the screen.
The Init State variables let us know the state of the game. This will determine when the game is paused or finished and when the next Asteroid should spawn.
The keys object gives a centralized way do determine what buttons the player is pressing at any given time. We will use this information during the game loop to control the ship.
Finally we create the player object and give it the initial properties. We also create a list that will hold all the Asteroids and a list for Projectiles.
function spawnAsteroids() { const side = randomInt(0,4); const radius = randomRange(10, 50); const randomVelocity = randomRange(-1, 1); const randomY = randomRange(0, canvas.height); const randomX = randomRange(0, canvas.width); const positions = [ {x: -radius, y: randomY}, // left {x: canvas.width + radius, y: randomY}, // right {x: randomX, y: -radius}, // top {x: randomX, y: canvas.height + radius} // bottom ]; const velocities = [ {x: 1, y: randomVelocity}, // left {x: -1, y: randomVelocity}, // right {x: randomVelocity, y: 1}, // top {x: randomVelocity, y: -1}, // bottom ]; const position = positions[side]; const velocity = velocities[side]; asteroids.push(new Asteroid(position, velocity, radius)); }
This function will be called whenever an Asteroid is created. It chooses a random side of the canvas and places the asteroid on that side with a velocity that will move it slowly across the screen.
function animate() { // Draw Background c.fillStyle = 'black'; c.fillRect(0,0,canvas.width, canvas.height); // Draw Projectiles for (let i = projectiles.length - 1; i >= 0; i--) { const projectile = projectiles[i]; projectile.update(c); // Garbage Collection for Projectiles if ( projectile.position.x + projectile.radius < 0 || projectile.position.x - projectile.radius > canvas.width || projectile.position.y + projectile.radius < 0 || projectile.position.y - projectile.radius > canvas.height ) projectiles.splice(i, 1); } // Draw Asteroids for (let i = asteroids.length - 1; i >= 0; i--) { const asteroid = asteroids[i]; asteroid.update(c); // Garbage Collection for Asteroids if ( asteroid.position.x + asteroid.radius < 0 || asteroid.position.x - asteroid.radius > canvas.width || asteroid.position.y + asteroid.radius < 0 || asteroid.position.y - asteroid.radius > canvas.height ) asteroids.splice(i, 1); // Projectile Collision Testing for (let j = projectiles.length - 1; j >= 0; j--) { const projectile = projectiles[j]; if (circleCollision(asteroid, projectile)) { if (asteroid.radius > 30) { asteroid.spawnChildren(asteroids); } asteroids.splice(i, 1); projectiles.splice(j, 1); } } // Player Collision Testing if (circleCollision(asteroid, player)) { console.log('Game Over'); gameOver = true; } } player.update(c); // Player Controls if (keys.w.pressed) { player.velocity.x = Math.cos(player.rotation) * speed; player.velocity.y = Math.sin(player.rotation) * speed; player.thrustersOn = true; } else { player.velocity.x *= friction; player.velocity.y *= friction; if (player.thrustersOn) player.thrustersOn = false; } if (keys.d.pressed) player.rotation += angularSpeed; else if (keys.a.pressed) player.rotation -= angularSpeed; if (keys.space.pressed) { player.shoot(projectiles, projectileSpeed); keys.space.pressed = false; } // Player Wraparound const margins = 15; if (player.position.x > canvas.width + margins) player.position.x = -margins; if (player.position.y > canvas.height + margins) player.position.y = -margins; if (player.position.x < -margins) player.position.x = canvas.width + margins; if (player.position.y < -margins) player.position.y = canvas.height + margins; if (!paused && !gameOver) { frame = window.requestAnimationFrame(animate); } else { clearInterval(asteroidInterval); paused && showMessage('Game Paused'); gameOver && showMessage('Game Over'); } }
Now to the meat of the game, the game loop! There is a lot here to chew on, but we can break it down into more easily digestable chunks.
// Draw Background c.fillStyle = 'black'; c.fillRect(0,0,canvas.width, canvas.height);
First we draw the background. This is a blank black screen and erases everything that was drawn in the previous frame.
// Draw Projectiles for (let i = projectiles.length - 1; i >= 0; i--) { const projectile = projectiles[i]; projectile.update(c); // Garbage Collection for Projectiles if ( projectile.position.x + projectile.radius < 0 || projectile.position.x - projectile.radius > canvas.width || projectile.position.y + projectile.radius < 0 || projectile.position.y - projectile.radius > canvas.height ) projectiles.splice(i, 1); }
Next we loop through the list of Projectiles and call their update method. This draws them on the screen and sets their next position.
We also do some Garbage Collection. If any projectiles have gone off the edge of the screen, we delete them from the game.
// Draw Asteroids for (let i = asteroids.length - 1; i >= 0; i--) { const asteroid = asteroids[i]; asteroid.update(c); // Garbage Collection for Asteroids if ( asteroid.position.x + asteroid.radius < 0 || asteroid.position.x - asteroid.radius > canvas.width || asteroid.position.y + asteroid.radius < 0 || asteroid.position.y - asteroid.radius > canvas.height ) asteroids.splice(i, 1); // Projectile Collision Testing for (let j = projectiles.length - 1; j >= 0; j--) { const projectile = projectiles[j]; if (circleCollision(asteroid, projectile)) { if (asteroid.radius > 30) { asteroid.spawnChildren(asteroids); } asteroids.splice(i, 1); projectiles.splice(j, 1); } } // Player Collision Testing if (circleCollision(asteroid, player)) { console.log('Game Over'); gameOver = true; } }
Now we do the same for each of the Asteroids in the game. First we update them, then delete any that have gone off the screen.
Then we do our Collision Testing! First we check if any Asteroid has hit a Projectile, and if they have, we delete each of them together. Also if the Asteroid was sufficiently large (radius > 30) it will spawn children!
Next we check if the Player has hit an Asteroid. If this happens, we set the game state to ‘Game Over’!
player.update(c); // Player Controls if (keys.w.pressed) { player.velocity.x = Math.cos(player.rotation) * speed; player.velocity.y = Math.sin(player.rotation) * speed; player.thrustersOn = true; } else { player.velocity.x *= friction; player.velocity.y *= friction; if (player.thrustersOn) player.thrustersOn = false; } if (keys.d.pressed) player.rotation += angularSpeed; else if (keys.a.pressed) player.rotation -= angularSpeed; if (keys.space.pressed) { player.shoot(projectiles, projectileSpeed); keys.space.pressed = false; } // Player Wraparound const margins = 15; if (player.position.x > canvas.width + margins) player.position.x = -margins; if (player.position.y > canvas.height + margins) player.position.y = -margins; if (player.position.x < -margins) player.position.x = canvas.width + margins; if (player.position.y < -margins) player.position.y = canvas.height + margins;
Now it’s time to update the Player! This is the moment where we check the game’s controls and have the ship respond to them. Movement, rotation, and shooting are all checked for.
Finally we check for what to do if the player goes off the screen. We don’t want to just delete the player, so we’ll do some tricks to make it so if the player goes off the screen in one direction, they’ll reappear on the opposite side of the screen!
if (!paused && !gameOver) { frame = window.requestAnimationFrame(animate); } else { clearInterval(asteroidInterval); paused && showMessage('Game Paused'); gameOver && showMessage('Game Over'); }
Last but not least, we check if the game has been paused or finished. If either of those are true, we will stop the game from progressing and instead show a message over the screen indicating as such. Otherwise we call for another frame to be added to the game and start the loop right over from the beginning!
window.addEventListener('keydown', event => { switch(event.code) { case 'KeyW': keys.w.pressed = true; break; case 'KeyA': keys.a.pressed = true; break; case 'KeyD': keys.d.pressed = true; break; case 'Space': event.preventDefault() keys.space.pressed = true; break; } }); window.addEventListener('keyup', (event) => { switch(event.code) { case 'KeyW': keys.w.pressed = false; break; case 'KeyA': keys.a.pressed = false; break; case 'KeyD': keys.d.pressed = false; break; case 'Space': keys.space.pressed = false; break; case 'Escape': if (!paused) paused = true; else resume(); break; } }); const ccwButton = document.querySelector('#rotate-ccw'); ccwButton.addEventListener('pointerdown', () => keys.a.pressed = true); ccwButton.addEventListener('pointerup', () => keys.a.pressed = false); ccwButton.addEventlistener('contextmenu', e => e.preventDefault()); const forwardButton = document.querySelector('#forward'); forwardButton.addEventListener('pointerdown', () => keys.w.pressed = true); forwardButton.addEventListener('pointerup', () => keys.w.pressed = false); forwardButton.addEventlistener('contextmenu', e => e.preventDefault()); const cwButton = document.querySelector('#rotate-cw'); cwButton.addEventListener('pointerdown', () => keys.d.pressed = true); cwButton.addEventListener('pointerup', () => keys.d.pressed = false); cwButton.addEventlistener('contextmenu', e => e.preventDefault()); const shootButton = document.querySelector('#shoot'); shootButton.addEventListener('click', () => !paused && !gameOver && player.shoot(projectiles, projectileSpeed) ); shootButton.addEventListener('contextmenu', e => e.preventDefault()); document.querySelector('#pause').addEventListener('click', (e) => { if (gameOver) return; if (!paused) paused = true; else resume(); e.target.blur() }); document.querySelector('#reset').addEventListener('click', (e) => { initialize(); e.target.blur(); });
We’re almost finished, but there’s a few little things we need to do before we can get this game working. First is setting up the browser’s event handlers. We need to add handlers to the browser window so that we can know when a keyboard key is pressed. We also need to check if any of the buttons we put on the page to control the game are pressed.
A quick note: We prevent the default behavior when Space is pressed, because it usually will cause the screen to scroll down, and we don’t want that happening every time we shoot! We also prevent the default behavior of right-clicking or long-pressing on the buttons, as that makes controlling the game on a phone or tablet rather frustrating!
function resume() { paused = false; asteroidInterval = setInterval(spawnAsteroids, asteroidTiming); animate(); } function initialize() { window.cancelAnimationFrame(frame); gameOver = false; paused = false; player.reset(canvas); asteroids = new Array(); projectiles = new Array(); clearInterval(asteroidInterval); asteroidInterval = setInterval(spawnAsteroids, asteroidTiming); animate(); } function showMessage(text) { c.fillStyle = '#000c'; c.fillRect(0,0,canvas.width,canvas.height); c.beginPath(); c.font = '24px sans-serif'; c.textAlign = 'center'; c.fillStyle = 'white' c.fillText(text, centerX, centerY); c.closePath(); }
We now have some functions that control the game state. Resume is called whenever we want to exit the Paused state. Initialize is called when we first start the game or restart it after a Game Over. And showMessage is a quick way to show some text on the center of the screen, we use this during both Paused states and Game Over states.
initialize();
Finally, we have the last line of the program. This initialize function call sets the ball rolling, setting everything up and starting the game loop.