Build Your Own Space Invaders++

A step-by-step guide to recreating the Space Invaders++ game from scratch: polygon aliens, grid movement, lives, rounds, and destructible barriers. All client-side with vanilla JavaScript and the Canvas 2D API.

The live version also includes custom key bindings, colour themes with localStorage persistence, particle effects, and high-score submission — this guide covers the core mechanics.

Step 1: The Canvas

Start with an HTML page containing a <canvas> element and a script block:

<canvas id="game" width="800" height="600"></canvas>
<script>
    var canvas = document.getElementById('game');
    var ctx = canvas.getContext('2d');
    var W = 800, H = 600;
</script>

The width and height attributes set the drawing buffer size. CSS can scale the display independently.

Step 2: Game Loop

Use requestAnimationFrame for smooth 60fps animation with delta-time capping:

var lastTime = 0;
function gameLoop(timestamp) {
    if (!lastTime) lastTime = timestamp;
    var dt = Math.min((timestamp - lastTime) / 1000, 0.05);
    lastTime = timestamp;
    update(dt);
    draw();
    requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);

The 0.05 cap prevents physics explosions if the tab is backgrounded and then refocused.

Step 3: Keyboard Input

Track which keys are pressed using a simple key map:

var keys = {};
window.addEventListener('keydown', function(e) {
    keys[e.code] = true;
    if (['ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', function(e) { keys[e.code] = false; });

e.preventDefault() stops the page from scrolling when you press arrow keys.

Step 4: The Player Ship

The ship is a polygon triangle pointing up. Define it relative to its centre:

var SHIP_POLYGON = [
    { x: 0, y: -12 },
    { x: 14, y: 10 },
    { x: 12, y: 14 },
    { x: 0, y: 8 },
    { x: -12, y: 14 },
    { x: -14, y: 10 },
];

Ship state includes position, velocity, and lives:

var player = {
    x: W / 2,
    y: H - 40,
    lives: 3,
    invincible: 0,
};

Controls update movement in update():

var speed = 300;
if (keys['ArrowLeft']) player.x -= speed * dt;
if (keys['ArrowRight']) player.x += speed * dt;
player.x = Math.max(20, Math.min(W - 20, player.x));

Clamping keeps the ship within screen bounds (no wrap-around needed).

Step 5: Alien Polygons

Aliens are hand-crafted polygon shapes, not procedurally generated. Each template is a closed polygon with bilateral symmetry (left and right sides mirror). Three tiers:

Squid (top row, highest score):

var SQUID = [
    { x: 0, y: -15 }, { x: 3, y: -13 }, { x: 6, y: -11 },
    { x: 8, y: -7 }, { x: 7, y: -2 }, { x: 9, y: 3 },
    { x: 7, y: 8 }, { x: 4, y: 13 }, { x: 1, y: 11 },
    { x: 0, y: 13 }, { x: -1, y: 11 }, { x: -4, y: 13 },
    { x: -7, y: 8 }, { x: -9, y: 3 }, { x: -7, y: -2 },
    { x: -8, y: -7 }, { x: -6, y: -11 }, { x: -3, y: -13 },
];

Crab (middle row): Wider silhouette with upward claw points and side legs — see the full reference.

Humanoid (bottom row, lowest score): Taller shape with a helmet head, arms extended outward, and spread legs — see the full reference.

Store the templates in an array with colours and point values:

var ALIEN_TEMPLATES = [
    { base: SQUID,    score: 100, colour: '#e74c3c' },
    { base: CRAB,     score: 50,  colour: '#3498db' },
    { base: HUMANOID, score: 20,  colour: '#f1c40f' },
];

Step 6: Alien Grid

Aliens are arranged in a grid. Each round creates a new grid:

var ALIEN_COLS = 7, ALIEN_ROWS = 3;

function createAliens(cols, rows) {
    var aliens = [];
    var spacingX = 60, spacingY = 50;
    var gridW = cols * spacingX, gridH = rows * spacingY;
    var startX = (W - gridW) / 2 + spacingX / 2;
    var startY = 60;
    for (var r = 0; r < rows; r++) {
        var templateIndex = Math.min(r, ALIEN_TEMPLATES.length - 1);
        for (var c = 0; c < cols; c++) {
            aliens.push({
                x: startX + c * spacingX,
                y: startY + r * spacingY,
                templateIndex: templateIndex,
                alive: true,
            });
        }
    }
    return aliens;
}

Row 0 gets the squid template, row 1 gets crab, rows 2+ get humanoid.

Step 7: Drawing Polygons

A reusable function to draw any polygon (ship, alien) with the game's outline style:

function drawPolygon(pts, cx, cy, color, scale) {
    ctx.save();
    ctx.translate(cx, cy);
    if (scale) ctx.scale(scale, scale);
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
    ctx.closePath();
    ctx.strokeStyle = color;
    ctx.lineWidth = 1;
    ctx.fillStyle = color;
    ctx.globalAlpha = 0.12;
    ctx.fill();
    ctx.globalAlpha = 1;
    ctx.stroke();
    ctx.restore();
}

Scale lets you make aliens slightly different sizes per tier.

Step 8: Alien Group Movement

Aliens move as a group — they drift sideways, then descend when hitting a wall:

var alienDir = 1;       // 1 = right, -1 = left
var alienSpeed = 60;    // pixels per second

function updateAliens(dt) {
    var hitWall = false;

    // Move all aliens
    for (var i = 0; i < aliens.length; i++) {
        if (!aliens[i].alive) continue;
        aliens[i].x += alienSpeed * alienDir * dt;
        if (aliens[i].x < 20 || aliens[i].x > W - 20) hitWall = true;
    }

    // If any alien hit a wall, reverse direction and descend
    if (hitWall) {
        alienDir *= -1;
        for (var i = 0; i < aliens.length; i++) {
            if (!aliens[i].alive) continue;
            aliens[i].y += 20;           // descend
            aliens[i].x += alienSpeed * alienDir * dt; // step away from wall
        }
    }
}

The descent amount (20px per wall hit) means aliens march downward as they zigzag.

Step 9: Bullets

Player bullets

Fire upward on Space key press:

var playerBullets = [];

function fireBullet() {
    if (fireCooldown > 0) return;
    playerBullets.push({
        x: player.x, y: player.y - 20,
        vy: -400,               // negative = upward
        life: 2.0,              // seconds
        w: 4, h: 12,
    });
    fireCooldown = 0.25;
}

Update bullet positions and remove those that leave the screen:

for (var i = playerBullets.length - 1; i >= 0; i--) {
    var b = playerBullets[i];
    b.y += b.vy * dt;
    b.life -= dt;
    if (b.life <= 0 || b.y < -20) playerBullets.splice(i, 1);
}

Alien bullets

Aliens fire randomly downward:

var alienBullets = [];
var alienFireTimer = 2.0;

function alienFire() {
    var alive = aliens.filter(function(a) { return a.alive; });
    if (alive.length === 0) return;
    var shooter = alive[Math.floor(Math.random() * alive.length)];
    alienBullets.push({
        x: shooter.x, y: shooter.y + 14,
        vy: 200,                // positive = downward
        w: 4, h: 8,
    });
}

Fire on a timer:

alienFireTimer -= dt;
if (alienFireTimer <= 0) {
    alienFire();
    alienFireTimer = 1.5 + Math.random() * 0.5;
}

Step 10: Collision Detection

Player bullet vs alien

Use the ray-casting algorithm to check if a bullet point is inside an alien's polygon:

function pointInPolygon(px, py, poly) {
    var inside = false, n = poly.length;
    for (var i = 0, j = n - 1; i < n; j = i++) {
        var xi = poly[i].x, yi = poly[i].y;
        var xj = poly[j].x, yj = poly[j].y;
        if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi)
            inside = !inside;
    }
    return inside;
}

Transform the alien's polygon from local to world coordinates before checking:

function getWorldPoints(poly, cx, cy) {
    var out = [];
    for (var i = 0; i < poly.length; i++)
        out.push({ x: poly[i].x + cx, y: poly[i].y + cy });
    return out;
}

Note: aliens don't rotate, so no rotation transform is needed.

Collision check:

for (var bi = playerBullets.length - 1; bi >= 0; bi--) {
    var bullet = playerBullets[bi], hit = false;
    for (var i = 0; i < aliens.length; i++) {
        if (!aliens[i].alive) continue;
        var wp = getWorldPoints(ALIEN_TEMPLATES[aliens[i].templateIndex].base, aliens[i].x, aliens[i].y);
        if (pointInPolygon(bullet.x, bullet.y, wp)) {
            aliens[i].alive = false;
            score += ALIEN_TEMPLATES[aliens[i].templateIndex].score;
            hit = true;
            break;
        }
    }
    if (hit) playerBullets.splice(bi, 1);
}

Alien bullet vs player

Simple AABB overlap check:

for (var i = 0; i < alienBullets.length; i++) {
    var b = alienBullets[i];
    if (b.x > player.x - 14 && b.x < player.x + 14 &&
        b.y > player.y - 16 && b.y < player.y + 16) {
        alienBullets.splice(i, 1);
        playerDie();
        break;
    }
}

Step 11: Lives System

var lives = 3;

function playerDie() {
    lives--;
    if (lives <= 0) {
        running = false;  // game over
    } else {
        // Respawn with invincibility
        player.x = W / 2;
        player.invincible = 1.5;
    }
}

During invincibility, make the ship blink by skipping draw on even blink frames:

if (player.invincible <= 0 || Math.floor(player.invincible * 10) % 2 === 0) {
    drawPolygon(SHIP_POLYGON, player.x, player.y, shipColour);
}

Step 12: Rounds

When all aliens are cleared, start a new round:

var round = 0;

function startRound() {
    round++;
    aliens = createAliens(ALIEN_COLS + round - 1, ALIEN_ROWS);
    playerBullets = [];
    alienBullets = [];
    alienDir = 1;
    alienSpeed = 60 + (round - 1) * 10;
}

Each round adds one more column and increases alien speed slightly.

Step 13: Game Over

Set running = false. The draw loop renders a semi-transparent overlay and "Game Over" text:

if (!running) {
    ctx.fillStyle = 'rgba(0,0,0,0.5)';
    ctx.fillRect(0, 0, W, H);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillStyle = '#eee';
    ctx.font = 'bold 48px sans-serif';
    ctx.fillText('Game Over', W / 2, H / 2 - 30);
    ctx.font = '24px sans-serif';
    ctx.fillText('Score: ' + score, W / 2, H / 2 + 20);
    ctx.font = '16px sans-serif';
    ctx.globalAlpha = 0.6;
    ctx.fillText('Enter to play again', W / 2, H / 2 + 65);
    ctx.globalAlpha = 1;
}

Also check if any alien reaches the bottom of the screen — instant game over:

for (var i = 0; i < aliens.length; i++) {
    if (aliens[i].alive && aliens[i].y > H - 60) {
        gameOver = true;
    }
}

Complete Example

A self-contained HTML file combining every step above — a playable Space Invaders clone with polygon aliens, grid movement, lives, and rounds:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Space Invaders++</title>
<style>
  body { margin: 0; background: #111; display: flex; justify-content: center; align-items: center; height: 100vh; }
  canvas { display: block; width: 800px; height: 600px; }
</style>
</head>
<body>
<canvas id="game" width="800" height="600"></canvas>
<script>
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
var W = 800, H = 600;
var keys = {};
var running = false;
var score = 0, round = 0;
var player, aliens = [], playerBullets = [], alienBullets = [];
var alienDir = 1, alienSpeed = 60, fireCooldown = 0, alienFireTimer = 2;
var lives = 3;

window.addEventListener('keydown', function(e) {
    keys[e.code] = true;
    if (['ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
    if (e.code === 'Enter' && !running) restart();
});
window.addEventListener('keyup', function(e) { keys[e.code] = false; });

function rand(min, max) { return Math.random() * (max - min) + min; }

var SHIP_POLYGON = [{ x: 0, y: -12 }, { x: 14, y: 10 }, { x: 12, y: 14 }, { x: 0, y: 8 }, { x: -12, y: 14 }, { x: -14, y: 10 }];

var SQUID = [
    { x: 0, y: -15 }, { x: 3, y: -13 }, { x: 6, y: -11 }, { x: 8, y: -7 },
    { x: 7, y: -2 }, { x: 9, y: 3 }, { x: 7, y: 8 }, { x: 4, y: 13 },
    { x: 1, y: 11 }, { x: 0, y: 13 }, { x: -1, y: 11 }, { x: -4, y: 13 },
    { x: -7, y: 8 }, { x: -9, y: 3 }, { x: -7, y: -2 }, { x: -8, y: -7 },
    { x: -6, y: -11 }, { x: -3, y: -13 },
];

var CRAB = [
    { x: 0, y: -11 }, { x: 4, y: -10 }, { x: 8, y: -8 }, { x: 12, y: -7 },
    { x: 9, y: -3 }, { x: 14, y: 1 }, { x: 10, y: 3 }, { x: 9, y: 7 },
    { x: 6, y: 11 }, { x: 3, y: 10 }, { x: 0, y: 12 }, { x: -3, y: 10 },
    { x: -6, y: 11 }, { x: -9, y: 7 }, { x: -10, y: 3 }, { x: -14, y: 1 },
    { x: -9, y: -3 }, { x: -12, y: -7 }, { x: -8, y: -8 }, { x: -4, y: -10 },
];

var HUMANOID = [
    { x: 0, y: -17 }, { x: 4, y: -15 }, { x: 7, y: -12 }, { x: 10, y: -8 },
    { x: 13, y: -5 }, { x: 11, y: -1 }, { x: 7, y: 1 }, { x: 9, y: 6 },
    { x: 7, y: 12 }, { x: 4, y: 10 }, { x: 0, y: 13 }, { x: -4, y: 10 },
    { x: -7, y: 12 }, { x: -9, y: 6 }, { x: -7, y: 1 }, { x: -11, y: -1 },
    { x: -13, y: -5 }, { x: -10, y: -8 }, { x: -7, y: -12 }, { x: -4, y: -15 },
];

var ALIEN_TEMPLATES = [
    { base: SQUID,    score: 100, colour: '#e74c3c' },
    { base: CRAB,     score: 50,  colour: '#3498db' },
    { base: HUMANOID, score: 20,  colour: '#f1c40f' },
];

function createAliens(cols, rows) {
    var list = [];
    var spacingX = 60, spacingY = 50;
    var gridW = cols * spacingX, gridH = rows * spacingY;
    var startX = (W - gridW) / 2 + spacingX / 2;
    var startY = 60;
    for (var r = 0; r < rows; r++) {
        var ti = Math.min(r, ALIEN_TEMPLATES.length - 1);
        for (var c = 0; c < cols; c++) {
            list.push({ x: startX + c * spacingX, y: startY + r * spacingY, templateIndex: ti, alive: true });
        }
    }
    return list;
}

function drawPolygon(pts, cx, cy, colour, scale) {
    ctx.save();
    ctx.translate(cx, cy);
    if (scale) ctx.scale(scale, scale);
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
    ctx.closePath();
    ctx.strokeStyle = colour;
    ctx.lineWidth = 1;
    ctx.fillStyle = colour;
    ctx.globalAlpha = 0.12;
    ctx.fill();
    ctx.globalAlpha = 1;
    ctx.stroke();
    ctx.restore();
}

function getWorldPoints(poly, cx, cy) {
    var out = [];
    for (var i = 0; i < poly.length; i++) out.push({ x: poly[i].x + cx, y: poly[i].y + cy });
    return out;
}

function pointInPolygon(px, py, poly) {
    var inside = false, n = poly.length;
    for (var i = 0, j = n - 1; i < n; j = i++) {
        var xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
        if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
    }
    return inside;
}

function playerDie() {
    lives--;
    if (lives <= 0) { running = false; return; }
    player.x = W / 2;
    player.invincible = 1.5;
}

function fireBullet() {
    if (fireCooldown > 0) return;
    playerBullets.push({ x: player.x, y: player.y - 20, vy: -400, life: 2.0, w: 4, h: 12 });
    fireCooldown = 0.25;
}

function alienFire() {
    var alive = [];
    for (var i = 0; i < aliens.length; i++) if (aliens[i].alive) alive.push(aliens[i]);
    if (alive.length === 0) return;
    var shooter = alive[randInt(0, alive.length - 1)];
    alienBullets.push({ x: shooter.x, y: shooter.y + 14, vy: 200, w: 4, h: 8 });
}

function randInt(min, max) { return Math.floor(rand(min, max + 1)); }

function startRound() {
    round++;
    aliens = createAliens(7 + round - 1, 3);
    playerBullets = [];
    alienBullets = [];
    alienDir = 1;
    alienSpeed = 60 + (round - 1) * 10;
    if (player) { player.x = W / 2; player.invincible = 1.5; }
}

function initGame() {
    player = { x: W / 2, y: H - 40, invincible: 0 };
    score = 0; round = 0; lives = 3; running = true;
    fireCooldown = 0; alienFireTimer = 2;
    startRound();
}

function update(dt) {
    if (!running) return;

    player.x += (keys['ArrowRight'] - keys['ArrowLeft']) * 300 * dt;
    player.x = Math.max(20, Math.min(W - 20, player.x));
    if (player.invincible > 0) player.invincible -= dt;

    fireCooldown = Math.max(0, fireCooldown - dt);
    if (keys['Space']) fireBullet();

    // Player bullets
    for (var i = playerBullets.length - 1; i >= 0; i--) {
        var b = playerBullets[i];
        b.y += b.vy * dt;
        b.life -= dt;
        if (b.life <= 0 || b.y < -20) playerBullets.splice(i, 1);
    }

    // Alien bullets
    for (var i = alienBullets.length - 1; i >= 0; i--) {
        var b = alienBullets[i];
        b.y += b.vy * dt;
        if (b.y > H + 20) alienBullets.splice(i, 1);
    }

    // Alien movement
    var hitWall = false;
    for (var i = 0; i < aliens.length; i++) {
        if (!aliens[i].alive) continue;
        aliens[i].x += alienSpeed * alienDir * dt;
        if (aliens[i].x < 20 || aliens[i].x > W - 20) hitWall = true;
    }
    if (hitWall) {
        alienDir *= -1;
        for (var i = 0; i < aliens.length; i++) {
            if (!aliens[i].alive) continue;
            aliens[i].y += 20;
            aliens[i].x += alienSpeed * alienDir * dt;
        }
    }

    // Alien fire timer
    alienFireTimer -= dt;
    if (alienFireTimer <= 0) { alienFire(); alienFireTimer = 1.5 + rand(0, 1); }

    // Alien reached bottom?
    for (var i = 0; i < aliens.length; i++) {
        if (aliens[i].alive && aliens[i].y > H - 60) { running = false; return; }
    }

    // Player bullet vs alien
    for (var bi = playerBullets.length - 1; bi >= 0; bi--) {
        var bullet = playerBullets[bi], hit = false;
        for (var i = 0; i < aliens.length; i++) {
            if (!aliens[i].alive) continue;
            var t = ALIEN_TEMPLATES[aliens[i].templateIndex];
            var wp = getWorldPoints(t.base, aliens[i].x, aliens[i].y);
            if (pointInPolygon(bullet.x, bullet.y, wp)) {
                aliens[i].alive = false;
                score += t.score;
                hit = true;
                break;
            }
        }
        if (hit) playerBullets.splice(bi, 1);
    }

    // Alien bullet vs player
    if (player.invincible <= 0) {
        for (var i = 0; i < alienBullets.length; i++) {
            var b = alienBullets[i];
            if (b.x > player.x - 14 && b.x < player.x + 14 &&
                b.y > player.y - 16 && b.y < player.y + 16) {
                alienBullets.splice(i, 1);
                playerDie();
                return;
            }
        }
    }

    // Win check
    var aliveCount = 0;
    for (var i = 0; i < aliens.length; i++) if (aliens[i].alive) aliveCount++;
    if (aliveCount === 0) startRound();
}

function draw() {
    ctx.fillStyle = '#111';
    ctx.fillRect(0, 0, W, H);

    // Aliens
    for (var i = 0; i < aliens.length; i++) {
        if (!aliens[i].alive) continue;
        var a = aliens[i];
        var t = ALIEN_TEMPLATES[a.templateIndex];
        drawPolygon(t.base, a.x, a.y, t.colour, 0.8 + a.templateIndex * 0.2);
    }

    // Player bullets
    for (var i = 0; i < playerBullets.length; i++) {
        var b = playerBullets[i];
        ctx.fillStyle = '#ff4444';
        ctx.fillRect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h);
    }

    // Alien bullets
    for (var i = 0; i < alienBullets.length; i++) {
        var b = alienBullets[i];
        ctx.fillStyle = '#ffaa00';
        ctx.fillRect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h);
    }

    // Player ship
    if (player.invincible <= 0 || Math.floor(player.invincible * 10) % 2 === 0) {
        drawPolygon(SHIP_POLYGON, player.x, player.y, '#4a9eff');
    }

    // HUD
    ctx.textAlign = 'left';
    ctx.textBaseline = 'top';
    ctx.fillStyle = '#eee';
    ctx.font = '18px sans-serif';
    ctx.fillText('Lives: ' + lives, 20, 10);

    ctx.textAlign = 'right';
    ctx.fillText('Score: ' + score, W - 20, 10);

    // Game over
    if (!running) {
        ctx.fillStyle = 'rgba(0,0,0,0.5)';
        ctx.fillRect(0, 0, W, H);
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillStyle = '#eee';
        ctx.font = 'bold 48px sans-serif';
        ctx.fillText('Game Over', W / 2, H / 2 - 30);
        ctx.font = '24px sans-serif';
        ctx.fillText('Score: ' + score, W / 2, H / 2 + 20);
        ctx.font = '16px sans-serif';
        ctx.globalAlpha = 0.6;
        ctx.fillText('Enter to play again', W / 2, H / 2 + 65);
        ctx.globalAlpha = 1;
    }
}

var lastTime = 0;
function gameLoop(timestamp) {
    if (!lastTime) lastTime = timestamp;
    var dt = Math.min((timestamp - lastTime) / 1000, 0.05);
    lastTime = timestamp;
    update(dt);
    draw();
    requestAnimationFrame(gameLoop);
}

function restart() { initGame(); }

initGame();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>

Open this file in a browser and you'll have a fully working Space Invaders clone built from the techniques in this guide. Left/Right to move, Space to fire, Enter to restart after game over.

Next Steps

  • Add particle effects on alien destruction
  • Add destructible barriers (shields) for the player to hide behind
  • Add sound effects using the Web Audio API
  • Implement power-ups (spread shot, shield, extra life)
  • Add a high score system with localStorage
  • Add mobile touch controls

Reference