Building a Simple Space Invaders-Style Game in HTML/JS/CSS (and Rebuilding in Phaser)

Building a Simple Space Invaders-Style Game in HTML/JS/CSS (and Rebuilding in Phaser)

HTML
JavaScript
Phaser
Game Development
Game Design
CSS
2021-12-28

Hey there, I’m Nate Ross! Today, I'm absolutely thrilled to walk you through building a simple “Space Invaders”-style game in good old HTML, CSS, and JavaScript.

Then, in the second part, I’ll show you how quickly we can recreate (and further expand) that same game using the Phaser framework. Let’s dive right in!


Before we code, let’s outline what we want:

  • A simple grid of enemy invaders at the top of the screen.
  • A player-controlled ship at the bottom that can move left/right and shoot projectiles.
  • If a projectile hits an invader, that invader disappears.
  • If the invaders reach the bottom, the player loses.

First, I'll create a basic HTML page. Let’s call it index.html:

<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Space Invaders (Vanilla JS)</title> <link rel="stylesheet" href="styles.css" /> </head> <body> <canvas id="gameCanvas" width="600" height="600"></canvas> <script src="game.js"></script> </body> </html>

Next, I’ll add some simple styling in styles.css. We just want to remove margins, ensure the canvas is centered, etc.

html, body { margin: 0; padding: 0; background: #000; font-family: sans-serif; } #gameCanvas { display: block; margin: 0 auto; background: #111; }

Now the fun part: let’s handle our game logic. We’ll use the canvas element, draw our player, move them around, create invaders, and detect collisions. In game.js:

const canvas = document.getElementById("gameCanvas"); const ctx = canvas.getContext("2d"); const canvasWidth = canvas.width; const canvasHeight = canvas.height; let rightPressed = false; let leftPressed = false; // Player const player = { x: canvasWidth / 2 - 20, y: canvasHeight - 50, width: 40, height: 20, speed: 5 }; // Projectiles let projectiles = []; // Invaders const invaders = []; const rows = 3; const columns = 8; const invaderWidth = 40; const invaderHeight = 20; const invaderPadding = 10; for (let r = 0; r < rows; r++) { for (let c = 0; c < columns; c++) { invaders.push({ x: c * (invaderWidth + invaderPadding) + 50, y: r * (invaderHeight + invaderPadding) + 30, width: invaderWidth, height: invaderHeight, alive: true }); } } // Key Listeners document.addEventListener("keydown", keyDownHandler); document.addEventListener("keyup", keyUpHandler); function keyDownHandler(e) { if (e.key === "Right" || e.key === "ArrowRight") { rightPressed = true; } else if (e.key === "Left" || e.key === "ArrowLeft") { leftPressed = true; } else if (e.key === " " || e.key === "Spacebar") { // Shoot shoot(); } } function keyUpHandler(e) { if (e.key === "Right" || e.key === "ArrowRight") { rightPressed = false; } else if (e.key === "Left" || e.key === "ArrowLeft") { leftPressed = false; } } function shoot() { projectiles.push({ x: player.x + player.width / 2 - 2, y: player.y, width: 4, height: 10, speed: 6 }); } function update() { // Move player if (rightPressed && player.x < canvasWidth - player.width) { player.x += player.speed; } else if (leftPressed && player.x > 0) { player.x -= player.speed; } // Move projectiles projectiles.forEach((proj) => { proj.y -= proj.speed; }); // Remove off-screen projectiles projectiles = projectiles.filter((proj) => proj.y > 0); // Check collisions for (let inv of invaders) { if (!inv.alive) continue; for (let proj of projectiles) { if ( proj.x < inv.x + inv.width && proj.x + proj.width > inv.x && proj.y < inv.y + inv.height && proj.y + proj.height > inv.y ) { inv.alive = false; proj.y = -999; // move projectile off screen } } } } function draw() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); // Draw player ctx.fillStyle = "lightgreen"; ctx.fillRect(player.x, player.y, player.width, player.height); // Draw invaders invaders.forEach((inv) => { if (inv.alive) { ctx.fillStyle = "red"; ctx.fillRect(inv.x, inv.y, inv.width, inv.height); } }); // Draw projectiles projectiles.forEach((proj) => { ctx.fillStyle = "yellow"; ctx.fillRect(proj.x, proj.y, proj.width, proj.height); }); } function loop() { update(); draw(); requestAnimationFrame(loop); } loop();

That’s the bare-bones version of the classic “Space Invaders.” We’re drawing the player, invaders, and projectiles on the canvas each frame.

We handle keyboard presses to move the player and shoot projectiles. When a projectile collides with an invader, that invader is marked as not alive.

Below is an embedded iframe demo of our vanilla HTML/CSS/JS “Space Invaders” in action. Use arrow keys to move left and right, and space bar to shoot! (Note: This is just a placeholder path—adapt it to your setup if you're replicating the project.)


Phaser is a popular 2D game framework for JavaScript developers. It's perfect for quickly spinning up game prototypes. If coding from scratch is a bit involved, Phaser provides a lot of functionality—like sprite management, collision detection, scene handling—out of the box.

To get started, we can include Phaser by downloading the library or referencing a CDN. Then, we initialize a Phaser.Game instance and define our scene(s). Below, I start with a straightforward example for the same Space-Invaders style mechanics:

<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Space Invaders (Phaser)</title> <script src="https://cdn.jsdelivr.net/npm/phaser@3/dist/phaser.min.js"></script> </head> <body> <div id="game"></div> <script src="main.js"></script> </body> </html>

Then in main.js, we define our preload, create, and update functions:

const config = { type: Phaser.AUTO, width: 600, height: 600, scene: { preload, create, update }, parent: 'game' }; let player; let cursors; let bullets; let invaders; let lastFired = 0; const game = new Phaser.Game(config); function preload() { // Load any assets if needed (e.g., images) } function create() { // Create player player = this.add.rectangle(300, 550, 40, 20, 0x00ff00); this.physics.add.existing(player); // Create bullet group bullets = this.physics.add.group({ defaultKey: null, maxSize: 10 }); // Create invaders invaders = this.physics.add.group(); for (let r = 0; r < 3; r++) { for (let c = 0; c < 8; c++) { let invader = this.add.rectangle(50 + c * 50, 30 + r * 30, 40, 20, 0xff0000); this.physics.add.existing(invader); invaders.add(invader); } } cursors = this.input.keyboard.createCursorKeys(); // Collision detection this.physics.add.overlap(bullets, invaders, handleHit, null, this); } function update(time) { // Move player if (cursors.left.isDown) { player.x -= 3; } else if (cursors.right.isDown) { player.x += 3; } // Shoot if (cursors.space.isDown && time > lastFired) { fireBullet(this); lastFired = time + 300; // 300ms delay } } function fireBullet(scene) { const bullet = scene.add.rectangle(player.x, player.y - 20, 4, 10, 0xffff00); scene.physics.add.existing(bullet); bullet.body.velocity.y = -300; bullets.add(bullet); } function handleHit(bullet, invader) { bullet.destroy(); invader.destroy(); }

That’s all there is to it! Phaser handles the physics, and we get a simple method to create objects. Collision detection is managed with this.physics.add.overlap() for bullet-to-invader interactions.

Below is an embedded iframe with a simplified Phaser version of our game in action. Again, you can move with left/right arrow keys and shoot with space bar.


If you want to take it a step further and introduce more features—like multiple scenes (Title, Main, and Game Over), score counters, and multiple lives—here’s a more advanced version of our Cosmic Invaders game in Phaser 3.60.

We’ll use three scenes (TitleScene, MainScene, GameOverScene) and store persistent game data (score and lives) in a simple object.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Cosmic Invaders (Phaser 3)</title> <script src="https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js"></script> </head> <body style="margin: 0; padding: 0; background: #000"> <script> // A few global game settings: const GAME_WIDTH = 400; const GAME_HEIGHT = 300; // We'll store shared data (score, lives) in a simple object: let gameData = { score: 0, lives: 3, }; class TitleScene extends Phaser.Scene { constructor() { super("TitleScene"); } preload() { // Generate star texture (a 2x2 white pixel). const starGraphics = this.make.graphics({ x: 0, y: 0, add: false }); starGraphics.fillStyle(0xffffff, 1); starGraphics.fillRect(0, 0, 2, 2); starGraphics.generateTexture("star", 2, 2); // Create a cosmic gradient-like background... let bgGraphics = this.make.graphics({ x: 0, y: 0, add: false }); bgGraphics.fillStyle(0x07051c, 1); bgGraphics.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); bgGraphics.fillStyle(0x291b51, 0.5); bgGraphics.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); bgGraphics.generateTexture("cosmicBg", GAME_WIDTH, GAME_HEIGHT); // Player texture: green rectangle with outline const playerGraphics = this.make.graphics({ x: 0, y: 0, add: false }); playerGraphics.fillStyle(0x00ff00, 1); playerGraphics.fillRect(-20, -10, 40, 20); playerGraphics.lineStyle(2, 0x66ff66, 0.7); playerGraphics.strokeRect(-20, -10, 40, 20); playerGraphics.generateTexture("player", 40, 20); // Enemy texture: red square with outline const enemyGraphics = this.make.graphics({ x: 0, y: 0, add: false }); enemyGraphics.fillStyle(0xff0000, 1); enemyGraphics.fillRect(-10, -10, 20, 20); enemyGraphics.lineStyle(2, 0xff6666, 0.7); enemyGraphics.strokeRect(-10, -10, 20, 20); enemyGraphics.generateTexture("enemy", 20, 20); // Bullet texture: narrow white rectangle const bulletGraphics = this.make.graphics({ x: 0, y: 0, add: false }); bulletGraphics.fillStyle(0xffffff, 1); bulletGraphics.fillRect(-2, -5, 4, 10); bulletGraphics.generateTexture("bullet", 4, 10); } create() { // Reset game data gameData.score = 0; gameData.lives = 3; // Add cosmic background this.add.image(0, 0, "cosmicBg").setOrigin(0); // Create starfield group this.stars = this.add.group(); for (let i = 0; i < 50; i++) { let star = this.add.image( Phaser.Math.Between(0, GAME_WIDTH), Phaser.Math.Between(0, GAME_HEIGHT), "star" ); star.setScale(Phaser.Math.FloatBetween(0.5, 1.5)); star.speed = Phaser.Math.Between(20, 60); this.stars.add(star); } // Title screen text this.titleText = this.add .text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20, "COSMIC INVADERS", { fontFamily: "Arial", fontSize: "20px", fontStyle: "bold", color: "#ffffff", }) .setOrigin(0.5); // Press SPACE prompt this.startText = this.add .text( GAME_WIDTH / 2, GAME_HEIGHT / 2 + 10, "[Press SPACE to Start]", { fontFamily: "Arial", fontSize: "14px", color: "#ffffff", } ) .setOrigin(0.5); this.spaceKey = this.input.keyboard.addKey( Phaser.Input.Keyboard.KeyCodes.SPACE ); } update(time, delta) { // Move stars downward this.stars.children.each((star) => { star.y += star.speed * (delta / 1000); if (star.y > GAME_HEIGHT) { star.y = 0; star.x = Phaser.Math.Between(0, GAME_WIDTH); } }); // Start on SPACE if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) { this.scene.start("MainScene"); } } } class MainScene extends Phaser.Scene { constructor() { super("MainScene"); } create() { // Background this.bg = this.add.image(0, 0, "cosmicBg").setOrigin(0); // Starfield this.stars = this.add.group(); for (let i = 0; i < 50; i++) { let star = this.add.image( Phaser.Math.Between(0, GAME_WIDTH), Phaser.Math.Between(0, GAME_HEIGHT), "star" ); star.setScale(Phaser.Math.FloatBetween(0.5, 1.5)); star.speed = Phaser.Math.Between(20, 60); this.stars.add(star); } // Variables this.playerSpeed = 200; this.bulletSpeed = 300; this.lastFired = 0; this.fireRate = 400; this.alienDirection = 1; this.alienMoveX = 25; this.alienMoveDown = 10; // Player this.player = this.physics.add.sprite( GAME_WIDTH / 2, GAME_HEIGHT - 30, "player" ); this.player.setCollideWorldBounds(true); // Bullets this.bullets = this.physics.add.group({ defaultKey: "bullet", maxSize: 20, }); // Enemies this.enemies = this.physics.add.group(); this.createEnemyWave(); // Overlaps for bullet/enemy collisions this.physics.add.overlap( this.bullets, this.enemies, this.hitEnemy, null, this ); // Input this.cursors = this.input.keyboard.createCursorKeys(); this.spaceKey = this.input.keyboard.addKey( Phaser.Input.Keyboard.KeyCodes.SPACE ); // UI text this.scoreText = this.add.text(10, 10, `Score: ${gameData.score}`, { fontFamily: "Arial", fontSize: "12px", color: "#ffffff", }); this.livesText = this.add .text(320, 10, `Lives: ${gameData.lives}`, { fontFamily: "Arial", fontSize: "12px", color: "#ffffff", }) .setOrigin(1, 0); } update(time, delta) { // Move starfield this.stars.children.each((star) => { star.y += star.speed * (delta / 1000); if (star.y > GAME_HEIGHT) { star.y = 0; star.x = Phaser.Math.Between(0, GAME_WIDTH); } }); // Move player this.player.setVelocityX(0); if (this.cursors.left.isDown) { this.player.setVelocityX(-this.playerSpeed); } else if (this.cursors.right.isDown) { this.player.setVelocityX(this.playerSpeed); } // Fire bullet if (this.spaceKey.isDown && time > this.lastFired) { this.fireBullet(); this.lastFired = time + this.fireRate; } // Move enemies horizontally and bounce let outOfBounds = false; this.enemies.children.each((enemy) => { enemy.x += this.alienMoveX * this.alienDirection * (delta / 1000); if (enemy.x < 10 || enemy.x > GAME_WIDTH - 10) { outOfBounds = true; } }); if (outOfBounds) { this.alienDirection *= -1; this.enemies.children.each((enemy) => { enemy.y += this.alienMoveDown; if (enemy.y > GAME_HEIGHT - 50) { this.lostLife(); } }); } } createEnemyWave() { let rows = 3; let cols = 8; let offsetX = 40; let offsetY = 30; let spacingX = 30; let spacingY = 25; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { let x = offsetX + c * spacingX; let y = offsetY + r * spacingY; let enemy = this.enemies.create(x, y, "enemy"); enemy.setOrigin(0.5); } } } fireBullet() { const bullet = this.bullets.get(this.player.x, this.player.y - 15); if (bullet) { bullet.setActive(true); bullet.setVisible(true); bullet.body.velocity.y = -this.bulletSpeed; } } hitEnemy(bullet, enemy) { bullet.destroy(); enemy.destroy(); gameData.score += 10; this.scoreText.setText(`Score: ${gameData.score}`); // If no active enemies remain, spawn new wave if (this.enemies.countActive() === 0) { this.createEnemyWave(); } } lostLife() { gameData.lives--; this.livesText.setText(`Lives: ${gameData.lives}`); // Move enemies back up a bit this.enemies.children.each((enemy) => { enemy.y -= 40; }); // Check for game over if (gameData.lives <= 0) { this.scene.start("GameOverScene"); } } } class GameOverScene extends Phaser.Scene { constructor() { super("GameOverScene"); } create() { this.add.image(0, 0, "cosmicBg").setOrigin(0); // Starfield this.stars = this.add.group(); for (let i = 0; i < 50; i++) { let star = this.add.image( Phaser.Math.Between(0, GAME_WIDTH), Phaser.Math.Between(0, GAME_HEIGHT), "star" ); star.setScale(Phaser.Math.FloatBetween(0.5, 1.5)); star.speed = Phaser.Math.Between(20, 60); this.stars.add(star); } // Game over text this.add .text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 20, "GAME OVER", { fontFamily: "Arial", fontSize: "20px", color: "#ff4444", }) .setOrigin(0.5); // Final score this.add .text( GAME_WIDTH / 2, GAME_HEIGHT / 2 + 10, `FINAL SCORE: ${gameData.score}`, { fontFamily: "Arial", fontSize: "14px", color: "#ffffff" } ) .setOrigin(0.5); // Play again this.add .text( GAME_WIDTH / 2, GAME_HEIGHT / 2 + 40, "[Press SPACE to Restart]", { fontFamily: "Arial", fontSize: "12px", color: "#ffffff" } ) .setOrigin(0.5); this.spaceKey = this.input.keyboard.addKey( Phaser.Input.Keyboard.KeyCodes.SPACE ); } update(time, delta) { // Move stars this.stars.children.each((star) => { star.y += star.speed * (delta / 1000); if (star.y > GAME_HEIGHT) { star.y = 0; star.x = Phaser.Math.Between(0, GAME_WIDTH); } }); // Restart on SPACE if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) { this.scene.start("TitleScene"); } } } // Game config const config = { type: Phaser.AUTO, width: GAME_WIDTH, height: GAME_HEIGHT, backgroundColor: "#000000", physics: { default: "arcade", arcade: { debug: false, }, }, scene: [TitleScene, MainScene, GameOverScene], }; // Create the game const game = new Phaser.Game(config); </script> </body> </html>

In this approach, we feature multiple scenes for the title screen, the main gameplay, and the game-over state. We also track a score and the number of lives.

Each time the enemies manage to get too low on the screen, you lose a life!

Here’s the full expanded version of our game running in an iframe. It’s served from phaser-game-1.html. Enjoy the starfield background, multiple scenes, score tracking, and more robust gameplay mechanics!


I hope this all-in-one guide showed you how to build a fun “Space Invaders”-style project from scratch and how a framework like Phaser can make your life so much easier—especially when you start adding features like multiple scenes, UI elements, physics, collisions, and more.

Whether you prefer the raw approach or the convenience of Phaser, remember that practice is key to mastering game development. So get out there, build something cool, and enjoy the ride!

Thank you for reading, and stay tuned for more fun dev adventures!