Midterm Project –

Concept

Retro-style 2D shooters have always been a favorite among gamers. Their simplicity, fast-paced action, and pixel-art aesthetics create an engaging experience. I set out to create my own Pixel Shooter Game using p5.js, adding dynamic enemy interactions, ammo management, health pickups, and immersive sound effects.

The idea was simple:

  • The player moves around the screen and shoots at approaching enemies.
  • The enemies chase the player and deal damage upon collision.
  • The player must manage ammo and collect pickups to survive.
  • The game ends when the player’s health reaches zero.

With these mechanics in mind, I designed a game that combines action, strategy, and survival elements.

I used a lot of sound effects from popular shooter games (Half Life, Counter Strike, some soundtracks from Red Faction II) and sandbox games like Minecraft.

I also used free, open-source sprites from different forums, God bless open-source!

How It Works

The game follows a state-based system, transitioning between:

  1. Loading Screen – Displays a progress bar while assets are loading.
  2. Start Menu – Shows the title, “Start Game” and “Instructions” buttons.
  3. Instructions Page – Displays movement, shooting, and gameplay tips.
  4. Play Mode – The core gameplay where enemies chase the player.
  5. Game Over Screen – Displays the final score and an option to restart.

Core Mechanics

  • Player Movement: Uses WASD or arrow keys to move.
  • Shooting: Press Space to fire bullets.
  • Enemy AI: Enemies spawn and move toward the player.
  • Health System: The player starts with 3 HP and dies after three enemy hits.
  • Ammo System: The player has 20 bullets max and must pick up ammo to reload.
  • Pickups: Random enemies drop ammo or health packs.

Code Highlights:

1) State-Based Game Flow

To keep the game organized, I used a state-based approach to control transitions:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function draw() {
background(20);
if (gameState === "loading") {
showLoadingScreen();
if (assetsLoaded >= totalAssets && loadingCompleteTime === 0) {
loadingCompleteTime = millis();
}
if (loadingCompleteTime > 0 && millis() - loadingCompleteTime > loadingDelay) {
gameState = "start";
}
}
else if (gameState === "start") {
showStartScreen();
startButton.show();
instructionsButton.show();
backButton.hide(); // Ensure Back button is hidden
if (!startMusic.isPlaying()) {
startMusic.loop();
startMusic.setVolume(0.5);
}
}
else if (gameState === "instructions") {
showInstructionsScreen();
startButton.hide();
instructionsButton.hide();
backButton.show();
}
else if (gameState === "play") {
runGame();
showScore();
startButton.hide();
instructionsButton.hide();
backButton.hide();
if (startMusic.isPlaying()) {
startMusic.stop();
}
if (!gameMusic.isPlaying()) {
gameMusic.loop();
gameMusic.setVolume(0.5);
}
}
else if (gameState === "dead") {
showDeadScreen();
startButton.hide();
instructionsButton.hide();
backButton.hide();
}
}
function draw() { background(20); if (gameState === "loading") { showLoadingScreen(); if (assetsLoaded >= totalAssets && loadingCompleteTime === 0) { loadingCompleteTime = millis(); } if (loadingCompleteTime > 0 && millis() - loadingCompleteTime > loadingDelay) { gameState = "start"; } } else if (gameState === "start") { showStartScreen(); startButton.show(); instructionsButton.show(); backButton.hide(); // Ensure Back button is hidden if (!startMusic.isPlaying()) { startMusic.loop(); startMusic.setVolume(0.5); } } else if (gameState === "instructions") { showInstructionsScreen(); startButton.hide(); instructionsButton.hide(); backButton.show(); } else if (gameState === "play") { runGame(); showScore(); startButton.hide(); instructionsButton.hide(); backButton.hide(); if (startMusic.isPlaying()) { startMusic.stop(); } if (!gameMusic.isPlaying()) { gameMusic.loop(); gameMusic.setVolume(0.5); } } else if (gameState === "dead") { showDeadScreen(); startButton.hide(); instructionsButton.hide(); backButton.hide(); } }
function draw() {
  background(20);

  if (gameState === "loading") {
    showLoadingScreen();
    if (assetsLoaded >= totalAssets && loadingCompleteTime === 0) {
      loadingCompleteTime = millis();
    }
    if (loadingCompleteTime > 0 && millis() - loadingCompleteTime > loadingDelay) {
      gameState = "start";
    }
  } 
  else if (gameState === "start") {
    showStartScreen();
    startButton.show();
    instructionsButton.show();
    backButton.hide(); // Ensure Back button is hidden
    
    if (!startMusic.isPlaying()) {
      startMusic.loop();
      startMusic.setVolume(0.5);
    }
  } 
  else if (gameState === "instructions") {
    showInstructionsScreen();
    startButton.hide();
    instructionsButton.hide();
    backButton.show();
  } 
  else if (gameState === "play") {
    runGame();
    showScore();
    startButton.hide();
    instructionsButton.hide();
    backButton.hide();
    
    if (startMusic.isPlaying()) {
      startMusic.stop();
    }
    
    if (!gameMusic.isPlaying()) {
      gameMusic.loop();
      gameMusic.setVolume(0.5);
    }
  } 
  else if (gameState === "dead") {
    showDeadScreen();
    startButton.hide();
    instructionsButton.hide();
    backButton.hide();
  }
}

Each function (showLoadingScreen(), runGame(), etc.) controls what is displayed based on the current state.


2) Player Shooting Mechanic

The player can shoot bullets, but with an ammo limit:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
class Player {
constructor() {
this.pos = createVector(width / 2, height / 2);
this.speed = 4;
this.movementDist = 0; // we update this in update()
this.ammo = 20;
this.health = 3;
this.lastDamageTime = 0; // Track last time the player took damage
}
update() {
...
}
show() {
push();
translate(this.pos.x, this.pos.y);
// Face the mouse (common in top-down shooters).
let angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x);
rotate(angle);
scale(0.25);
// Decide idle or move frames
let moving = (this.movementDist > 0.1);
// Pick frame index
let frameIndex;
imageMode(CENTER);
if (moving) {
frameIndex = floor(frameCount / 6) % playerMoveSprites.length;
image(playerMoveSprites[frameIndex], 0, 0);
} else {
frameIndex = floor(frameCount / 12) % playerIdleSprites.length;
image(playerIdleSprites[frameIndex], 0, 0);
}
pop();
this.showHealthBar();
}
showHealthBar() {
...
}
shoot() {
// Fire only if enough time has passed since last shot AND we have ammo left
if (this.ammo > 0 && millis() - lastShotTime > 200) {
bullets.push(new Bullet(this.pos.x, this.pos.y));
lastShotTime = millis();
this.ammo--; // reduce ammo by 1
}
if (gunSound)
gunSound.play();
}
}
class Player { constructor() { this.pos = createVector(width / 2, height / 2); this.speed = 4; this.movementDist = 0; // we update this in update() this.ammo = 20; this.health = 3; this.lastDamageTime = 0; // Track last time the player took damage } update() { ... } show() { push(); translate(this.pos.x, this.pos.y); // Face the mouse (common in top-down shooters). let angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x); rotate(angle); scale(0.25); // Decide idle or move frames let moving = (this.movementDist > 0.1); // Pick frame index let frameIndex; imageMode(CENTER); if (moving) { frameIndex = floor(frameCount / 6) % playerMoveSprites.length; image(playerMoveSprites[frameIndex], 0, 0); } else { frameIndex = floor(frameCount / 12) % playerIdleSprites.length; image(playerIdleSprites[frameIndex], 0, 0); } pop(); this.showHealthBar(); } showHealthBar() { ... } shoot() { // Fire only if enough time has passed since last shot AND we have ammo left if (this.ammo > 0 && millis() - lastShotTime > 200) { bullets.push(new Bullet(this.pos.x, this.pos.y)); lastShotTime = millis(); this.ammo--; // reduce ammo by 1 } if (gunSound) gunSound.play(); } }
class Player {
  constructor() {
    this.pos = createVector(width / 2, height / 2);
    this.speed = 4;
    this.movementDist = 0; // we update this in update()
    this.ammo = 20;  
    this.health = 3;
    this.lastDamageTime = 0;  // Track last time the player took damage


  }

  update() {
   ...
  }

  show() {
    push();
    translate(this.pos.x, this.pos.y);

    // Face the mouse (common in top-down shooters).
    let angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x);
    rotate(angle);
    scale(0.25);


    // Decide idle or move frames
    let moving = (this.movementDist > 0.1);

    // Pick frame index
    let frameIndex;
      imageMode(CENTER);

    if (moving) {
      frameIndex = floor(frameCount / 6) % playerMoveSprites.length;
      image(playerMoveSprites[frameIndex], 0, 0);
    } else {
      frameIndex = floor(frameCount / 12) % playerIdleSprites.length; 
      image(playerIdleSprites[frameIndex], 0, 0);
    }

    pop();
    
    this.showHealthBar();

  }
  
    showHealthBar() {
    ...
  }

  shoot() {
    // Fire only if enough time has passed since last shot AND we have ammo left
    if (this.ammo > 0 && millis() - lastShotTime > 200) {
      bullets.push(new Bullet(this.pos.x, this.pos.y));
      lastShotTime = millis();
      this.ammo--;  // reduce ammo by 1
    }
    
      if (gunSound) 
        gunSound.play();
  }
}

This ensures players can’t spam bullets, adding a strategic element to gameplay.

3) Enemy AI and Collision Handling

Enemies move toward the player, and if they collide, the player takes damage:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Enemy-player collisions (Fixed)
for (let enemy of enemies) {
let dPlayer = dist(player.pos.x, player.pos.y, enemy.pos.x, enemy.pos.y);
// Only take damage if enough time has passed (e.g., 1 second)
if (dPlayer < 30 && millis() - player.lastDamageTime > 1000) {
player.health--;
player.lastDamageTime = millis(); // Update last hit time
if (hitSound) {
hitSound.play();
}
if (player.health <= 0) {
if(playerDeathSound) {
playerDeathSound.play();
}
gameState = 'dead';
}
}
}
// Enemy-player collisions (Fixed) for (let enemy of enemies) { let dPlayer = dist(player.pos.x, player.pos.y, enemy.pos.x, enemy.pos.y); // Only take damage if enough time has passed (e.g., 1 second) if (dPlayer < 30 && millis() - player.lastDamageTime > 1000) { player.health--; player.lastDamageTime = millis(); // Update last hit time if (hitSound) { hitSound.play(); } if (player.health <= 0) { if(playerDeathSound) { playerDeathSound.play(); } gameState = 'dead'; } } }
// Enemy-player collisions (Fixed)
for (let enemy of enemies) {
  let dPlayer = dist(player.pos.x, player.pos.y, enemy.pos.x, enemy.pos.y);
  
  // Only take damage if enough time has passed (e.g., 1 second)
  if (dPlayer < 30 && millis() - player.lastDamageTime > 1000) {
    player.health--;  
    player.lastDamageTime = millis(); // Update last hit time
    
    if (hitSound) {
      hitSound.play();
    }
    
    if (player.health <= 0) {
      if(playerDeathSound) {
        playerDeathSound.play();
      }
      gameState = 'dead';
    }
  }
}

This prevents instant death by implementing a damage cooldown.

4) Dynamic Background Music

Different background tracks play in menu and play modes:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
else if (gameState === "play") {
runGame();
showScore();
startButton.hide();
instructionsButton.hide();
backButton.hide();
if (startMusic.isPlaying()) {
startMusic.stop();
}
if (!gameMusic.isPlaying()) {
gameMusic.loop();
gameMusic.setVolume(0.5);
}
}
else if (gameState === "play") { runGame(); showScore(); startButton.hide(); instructionsButton.hide(); backButton.hide(); if (startMusic.isPlaying()) { startMusic.stop(); } if (!gameMusic.isPlaying()) { gameMusic.loop(); gameMusic.setVolume(0.5); } }
else if (gameState === "play") {
  runGame();
  showScore();
  startButton.hide();
  instructionsButton.hide();
  backButton.hide();
  
  if (startMusic.isPlaying()) {
    startMusic.stop();
  }
  
  if (!gameMusic.isPlaying()) {
    gameMusic.loop();
    gameMusic.setVolume(0.5);
  }
}

This immerses the player by dynamically switching music.

Challenges and Improvements

1) Handling Enemy Collisions Fairly

Initially, enemies instantly killed the player if they were touching them. To fix this, I added a damage cooldown, so players have 1 second of immunity between hits.

2) Centering Buttons Properly

Buttons were misaligned when resizing the canvas. Instead of manually placing them, I used dynamic centering.

3) Preventing Accidental Game Starts

Initially, pressing any key started the game. To fix this, I made keyPressed() work only in play mode.

Final Thoughts

This Pixel Shooter Game was a fun challenge that combined:

  • Game physics (enemy movement, shooting mechanics)
  • User experience improvements (better UI, centered buttons)
  • Audio immersion (different music for each state)
  • Optimization tricks (cropping backgrounds, limiting bullet spam)

Possible Future Improvements

  • Add power-ups (e.g., speed boost, rapid fire)
  • Implement different enemy types
  • Introduce a high score system
  • Introduce multiplayer using socket.io (websocket server connection, so two different clients could play on separate machines)

This project demonstrates how p5.js can create interactive, engaging 2D games while keeping code structured and scalable.

Sketch (Click Here to Open Full Screen)

 

Leave a Reply