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:
- Loading Screen – Displays a progress bar while assets are loading.
- Start Menu – Shows the title, “Start Game” and “Instructions” buttons.
- Instructions Page – Displays movement, shooting, and gameplay tips.
- Play Mode – The core gameplay where enemies chase the player.
- 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:
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:
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:
// 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:
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)