Midterm Project – Space Shooter

Concept:
My midterm project was inspired by classic arcade games like Space Invaders and Galaga. I wanted to create my own shoot ‘em up game set in space, and try to capture that retro feel while throwing some new elements into the mix.
I made the choice to have the player character and their bullets be pixelated, while the enemies have a more modern look and shoot laser beams in order to create some distinct contrast. I also introduced additional obstacles in the form of slow-moving asteroids and diagonally-moving comets, both of which share in the more modern appearance. I only included sound effects relevant to the player character, so they lean towards the older side.

How it Works:
As for the actual gameplay, I decided to confine the player to a single static screen, where obstacles (including enemies) will continually descend from the top. Since I didn’t design multiple levels or an upward scrolling mechanic, I chose to increase the spawn rate of obstacles as the round goes on to increase the difficulty. The player will continue until their health runs out, at which point they are prompted to save their score.
The player earns points by shooting and destroying an obstacle, but they will also earn a smaller amount for simply allowing the obstacle to pass them and be cleared off-screen. If the player collides with the obstacle, they will instead lose points and destroy the obstacle.

Highlights:
Using OOP to manage different obstacle sub-types + Detecting and handling collisions during gameplay:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Obstacle {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 32;
this.health = 3;
}
display() {
this.update();
circle(this.x,this.y,this.radius*2);
}
update() {
if (gameRunning()) {
this.y += 0.5;
}
}
check() {
if (this.health <= 0) {return}
// // Flag obstacles below screen to be cleared
if (!onScreen(this)) {
// console.log("Destroy offscreen");
this.health = 0;
game.score += 5;
}
// // Check collision w/ player
if (checkCollision(this, game.player)) {
this.health = 0;
if (!game.player.shielded) {
game.score -= 10;
game.player.health -= 1;
playHurt();
if (game.player.health <= 0) {return}
game.player.iframes = 60;
game.player.shielded = true;
}
}
// // Check collision w/ player bullets
for (let i = 0; i < game.player.bullets.length; i++) {
if (checkCollision(this, game.player.bullets[i])) {
if (!game.player.bullets[i].spent) {
game.player.bullets[i].spent = true;
this.health -= 1;
if (this.health <= 0) {
game.score += 20;
}
}
}
}
}
}
class Enemy extends Obstacle{
constructor(x, y) {
// // Gameplay attributes
super(x, y);
this.radius = 32;
this.sprite = enemySprite;
this.firerate = 0.3;
this.offset = random(1000);
}
display() {
this.update();
image(this.sprite, this.x, this.y, 64, 60);
if (debug) {circle(this.x, this.y, this.radius*2)}
}
update() {
if (gameRunning() && onScreen(this)) {
this.x += sin((frameCount + this.offset) * 0.05) * 2;
this.y += 0.5;
if (triggerFire(this.firerate)) {
append(game.enemyBullets, new Bullet(
this.x, this.y+this.radius));
}
}
}
}
class Obstacle { constructor(x, y) { this.x = x; this.y = y; this.radius = 32; this.health = 3; } display() { this.update(); circle(this.x,this.y,this.radius*2); } update() { if (gameRunning()) { this.y += 0.5; } } check() { if (this.health <= 0) {return} // // Flag obstacles below screen to be cleared if (!onScreen(this)) { // console.log("Destroy offscreen"); this.health = 0; game.score += 5; } // // Check collision w/ player if (checkCollision(this, game.player)) { this.health = 0; if (!game.player.shielded) { game.score -= 10; game.player.health -= 1; playHurt(); if (game.player.health <= 0) {return} game.player.iframes = 60; game.player.shielded = true; } } // // Check collision w/ player bullets for (let i = 0; i < game.player.bullets.length; i++) { if (checkCollision(this, game.player.bullets[i])) { if (!game.player.bullets[i].spent) { game.player.bullets[i].spent = true; this.health -= 1; if (this.health <= 0) { game.score += 20; } } } } } } class Enemy extends Obstacle{ constructor(x, y) { // // Gameplay attributes super(x, y); this.radius = 32; this.sprite = enemySprite; this.firerate = 0.3; this.offset = random(1000); } display() { this.update(); image(this.sprite, this.x, this.y, 64, 60); if (debug) {circle(this.x, this.y, this.radius*2)} } update() { if (gameRunning() && onScreen(this)) { this.x += sin((frameCount + this.offset) * 0.05) * 2; this.y += 0.5; if (triggerFire(this.firerate)) { append(game.enemyBullets, new Bullet( this.x, this.y+this.radius)); } } } }
class Obstacle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.radius = 32;
    this.health = 3;
  }
  display() {
    this.update();
    circle(this.x,this.y,this.radius*2);
  }
  update() {
    if (gameRunning()) {
      this.y += 0.5;
    }
  }
  check() {
    if (this.health <= 0) {return}
    // // Flag obstacles below screen to be cleared
    if (!onScreen(this)) {
      // console.log("Destroy offscreen");
      this.health = 0;
      game.score += 5;
    }
    // // Check collision w/ player
    if (checkCollision(this, game.player)) {
      this.health = 0;
      if (!game.player.shielded) {
        game.score -= 10;
        game.player.health -= 1;
        playHurt();
        if (game.player.health <= 0) {return}
        game.player.iframes = 60;
        game.player.shielded = true;
      }
    }
    // // Check collision w/ player bullets
    for (let i = 0; i < game.player.bullets.length; i++) {
      if (checkCollision(this, game.player.bullets[i])) {
        if (!game.player.bullets[i].spent) {
          game.player.bullets[i].spent = true;
          this.health -= 1;
          if (this.health <= 0) {
            game.score += 20;
          }
        }
      }
    }
  }
}
class Enemy extends Obstacle{
  constructor(x, y) {
    // // Gameplay attributes
    super(x, y);
    this.radius = 32;
    this.sprite = enemySprite;
    this.firerate = 0.3;
    this.offset = random(1000);
  }
  display() {
    this.update();
    image(this.sprite, this.x, this.y, 64, 60);
    if (debug) {circle(this.x, this.y, this.radius*2)}
  }
  update() {
    if (gameRunning() && onScreen(this)) {
      this.x += sin((frameCount + this.offset) * 0.05) * 2;
      this.y += 0.5;
      if (triggerFire(this.firerate)) {
        append(game.enemyBullets, new Bullet(
          this.x, this.y+this.radius));
      }
    }
  }
}

Using helper functions instead of cramming everything in-line:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// // Determine whether to shoot on current frame
function triggerFire(rate) {
let trigger = Math.floor(60 / rate);
return frameCount % trigger === 0;
}
// // Check collision of two objects (circle bounding)
function checkCollision(one, two) {
if (dist(one.x, one.y, two.x, two.y) <=
(one.radius + two.radius)) {return true}
return false;
}
// // Spawn a new enemy/asteroid/comet
function spawnObstacle(type) {
let newX = Math.floor(random(30, width - 30));
let newY = Math.floor(random(-50, -200));
let obs;
switch(type) {
case 0:
obs = new Enemy(newX, newY);
append(game.enemies, obs);
break;
case 1:
obs = new Asteroid(newX, newY);
append(game.asteroids, obs);
break;
case 2:
newX = newX % 30;
obs = new Comet(newX, newY);
append(game.comets, obs);
break;
default:
console.log("Spawn " + type);
}
append(game.obstacles[type], obs);
if (debug) {console.log("Spawn new " + type)}
}
// // Standardize button styling for menu
function doStyle(elem) {
// // Styling
elem.style("font-size", "16px");
elem.style("font-family", "sans-serif");
elem.style("background-color", "rgb(20,20,20)");
elem.style("color", "whitesmoke");
elem.style("cursor", "pointer");
// // Hover behaviour
elem.mouseOver(() => {
elem.style("background-color", "firebrick");
});
elem.mouseOut(() => {
elem.style("background-color", "rgb(20, 20, 20)");
});
}
// // Handle gameover
function triggerGameover() {
gameState = 'gameover';
game_bgm.stop();
// player_shoot.stop();
// player_hurt.stop();
if (game) {
game.gameover = true;
// // Play win sound for score (temp)
if (game.score > 500) {
win_sound.play();
return;
}
}
lose_sound.play();
}
// // Determine whether to shoot on current frame function triggerFire(rate) { let trigger = Math.floor(60 / rate); return frameCount % trigger === 0; } // // Check collision of two objects (circle bounding) function checkCollision(one, two) { if (dist(one.x, one.y, two.x, two.y) <= (one.radius + two.radius)) {return true} return false; } // // Spawn a new enemy/asteroid/comet function spawnObstacle(type) { let newX = Math.floor(random(30, width - 30)); let newY = Math.floor(random(-50, -200)); let obs; switch(type) { case 0: obs = new Enemy(newX, newY); append(game.enemies, obs); break; case 1: obs = new Asteroid(newX, newY); append(game.asteroids, obs); break; case 2: newX = newX % 30; obs = new Comet(newX, newY); append(game.comets, obs); break; default: console.log("Spawn " + type); } append(game.obstacles[type], obs); if (debug) {console.log("Spawn new " + type)} } // // Standardize button styling for menu function doStyle(elem) { // // Styling elem.style("font-size", "16px"); elem.style("font-family", "sans-serif"); elem.style("background-color", "rgb(20,20,20)"); elem.style("color", "whitesmoke"); elem.style("cursor", "pointer"); // // Hover behaviour elem.mouseOver(() => { elem.style("background-color", "firebrick"); }); elem.mouseOut(() => { elem.style("background-color", "rgb(20, 20, 20)"); }); } // // Handle gameover function triggerGameover() { gameState = 'gameover'; game_bgm.stop(); // player_shoot.stop(); // player_hurt.stop(); if (game) { game.gameover = true; // // Play win sound for score (temp) if (game.score > 500) { win_sound.play(); return; } } lose_sound.play(); }
// // Determine whether to shoot on current frame
function triggerFire(rate) {
  let trigger = Math.floor(60 / rate);
  return frameCount %  trigger === 0;
}
// // Check collision of two objects (circle bounding)
function checkCollision(one, two) {
  if (dist(one.x, one.y, two.x, two.y) <= 
      (one.radius + two.radius)) {return true}
  return false;
}
// // Spawn a new enemy/asteroid/comet
function spawnObstacle(type) {
  let newX = Math.floor(random(30, width - 30));
  let newY = Math.floor(random(-50, -200));
  let obs;
  switch(type) {
    case 0:
      obs = new Enemy(newX, newY);
      append(game.enemies, obs);
      break;
    case 1:
      obs = new Asteroid(newX, newY);
      append(game.asteroids, obs);
      break;
    case 2:
      newX = newX % 30;
      obs = new Comet(newX, newY);
      append(game.comets, obs);
      break;
    default:
      console.log("Spawn " + type);
  }
  append(game.obstacles[type], obs);
  if (debug) {console.log("Spawn new " + type)}
}
// // Standardize button styling for menu
function doStyle(elem) {
  // // Styling
  elem.style("font-size", "16px");
  elem.style("font-family", "sans-serif");
  elem.style("background-color", "rgb(20,20,20)");
  elem.style("color", "whitesmoke");
  elem.style("cursor", "pointer");
  // // Hover behaviour
  elem.mouseOver(() => {
    elem.style("background-color", "firebrick");
  });
  elem.mouseOut(() => {
    elem.style("background-color", "rgb(20, 20, 20)");
  });
}
// // Handle gameover
function triggerGameover() {
  gameState = 'gameover';
  game_bgm.stop();
  // player_shoot.stop();
  // player_hurt.stop();
  if (game) {
    game.gameover = true;
    // // Play win sound for score (temp)
    if (game.score > 500) {
      win_sound.play();
      return;
    }
  }
  lose_sound.play();
}

Areas for Improvement:
Since I spent most of my time on the gameplay itself, I feel like I lost out on the visual appeal in several places. For example, my menu screen has some styling on the buttons, but the font is fairly standard instead of using a pixelated font to add to the retro aesthetic. I also had started to work on a leaderboard system, but I belatedly realized that it would download a CSV file to the computer instead of saving the results to the relevant asset file.
Another area for improvement is the gameplay itself. I began to add power ups that would spawn in the playable area, but didn’t have time to fully implement them. The sound design was also a last minute addition that I completely forgot about until I started writing the blog post. Finally, the difficulty ramp-up could use some adjustment. Each obstacle type has semi-randomized parameters within differing bounds, but it still feels like something is a bit off about it.


(Fullscreen)

Leave a Reply