Test store. Prices are the planned list prices stored as test data, license terms are drafts, and payments go to a Stripe test-mode account. No real money moves.

Space Shooter in HTML5 Canvas: Full Code and Tutorial

This space shooter is one HTML file of 356 lines of plain JavaScript: a drone fleet that sweeps and steps down like Space Invaders, rocks that split like Asteroids, homing missiles, waves that get harder, and keyboard or touch controls. Play it above, copy the code, save it as space-shooter.html and open it in a browser. It's MIT-licensed.

Runs in your browser in a sandbox · 357 lines · no downloads

Nova Patrol is a complete space shooter: a fleet of drones sweeps across the screen and steps closer each time it hits an edge, rocks drift down and split when you shoot them, and from wave 3 some enemy shots are homing missiles. This HTML5 shooter game tutorial walks through the code that makes it work, from the fixed-timestep loop to bullets, waves and collisions, and it's all in one file you can copy.

How to run it

  1. Press Copy code above, or Download .html.
  2. Paste it into a new file called space-shooter.html in any text editor.
  3. Open the file in a browser. That's it: no server, library or build step.

You can also paste it into the HTML game code runner, change FIRE_DELAY or LIVES, and press Run to see the difference.

What the HTML, CSS and JavaScript each do

If you want to create a simple shooting game using HTML5, CSS and JS, this file shows how little each part needs to do. The HTML is a single <canvas> element. The CSS makes it fill the window and sets touch-action: none so dragging a finger moves the ship instead of scrolling the page. Everything else is JavaScript drawing on the canvas with the Canvas API: no images, no sprites and no DOM elements per bullet, which keeps the game fast with hundreds of particles on screen.

How the HTML5 shooter code works

1. Settings you can change

The first lines hold the numbers that shape how the game feels. FIRE_DELAY sets the rate of fire (0.18 seconds is about five shots a second), and GAP_X and GAP_Y space out the fleet.

space-shooter.html
const VIEW = 480;                // world units along the short side of the screen
const STEP = 1 / 120;            // fixed simulation step in seconds
const PLAYER_SPEED = 300;        // world units per second
const FIRE_DELAY = 0.18;         // seconds between player shots
const BULLET_SPEED = 640;        // world units per second
const LIVES = 3;
const GAP_X = 52, GAP_Y = 36;    // spacing of the drone fleet

2. World units instead of pixels

A shooter has to play the same on a phone and a monitor, so the game logic never uses pixels. The short side of the screen is always 480 world units, and resize() works out how many CSS pixels one unit is. A desktop window of 900 by 675 becomes a world of 640 by 480; a phone held upright becomes 480 wide and about 790 tall. Speeds, sizes and hit radii stay the same everywhere, and the drawing code applies the scale with a single setTransform call that also includes devicePixelRatio, so the game is sharp on high-DPI screens.

js
  const dpr = window.devicePixelRatio || 1;
  canvas.style.width = window.innerWidth + "px";
  canvas.style.height = window.innerHeight + "px";
  canvas.width = Math.round(window.innerWidth * dpr);
  canvas.height = Math.round(window.innerHeight * dpr);
  scale = Math.min(window.innerWidth, window.innerHeight) / VIEW;
  W = window.innerWidth / scale;
  H = window.innerHeight / scale;

3. Waves: building the fleet

nextWave() lays out the drones in a grid. Wave 1 has 3 rows, wave 2 has 4 and every later wave has 5, with up to 8 columns depending on the screen's width. The interesting line is the step-down size. On a wide screen the fleet sweeps a long way before it bounces; on a tall phone it bounces often. To make both take about the same time to land, the game divides the distance to the red line by the number of sweeps that fit in 2,700 units of travel.

js
function nextWave() {
  wave++;
  const rowsN = Math.min(5, 2 + wave), colsN = Math.max(4, Math.min(8, Math.floor((W - 80) / GAP_X)));
  enemies = [];
  for (let r = 0; r < rowsN; r++) for (let c = 0; c < colsN; c++) enemies.push({ r, c, x: 0, y: 0, alive: true });
  // Step-down size: about 2,700 units of sweeping before a full fleet lands, on any screen shape.
  // Wide screens get long sweeps and small steps; tall phones get short sweeps and bigger steps.
  const sweep = W - 48 - (colsN - 1) * GAP_X, passes = Math.max(8, Math.round(2700 / sweep));
  const bottom = 64 + (rowsN - 1) * GAP_Y + 14;
  fleet = { x: (W - (colsN - 1) * GAP_X) / 2, y: 64, dir: 1, rows: rowsN, total: rowsN * colsN, drop: Math.max(6, (lineY() - bottom) / passes) };
  placeFleet();
  shots = [];
  fireTimer = 1.2;
  rockTimer = 2.5;
  banner = 1.6;
}

4. The loop: fixed steps, smooth frames

requestAnimationFrame calls loop() once per screen refresh. The loop adds the elapsed time to an accumulator and runs update() in steps of exactly 1/120 of a second, then draws once. This is the fixed-timestep pattern from Glenn Fiedler's article Fix Your Timestep. It makes the game run at the same speed at 60 Hz and 144 Hz, and it's also what keeps collisions reliable, as step 8 explains.

js
function loop(now) {
  const dt = Math.min(0.25, (now - last) / 1000);
  last = now;
  for (const s of stars) { s.y += s.s * dt * (state === "playing" ? 1.5 : 0.4); if (s.y > 1) { s.y -= 1; s.x = Math.random(); } }
  if (state === "playing") {
    acc += dt;
    while (acc >= STEP && state === "playing") { update(STEP); acc -= STEP; }
  }
  moveSparks(dt);
  draw();
  requestAnimationFrame(loop);
}

5. Moving the fleet like Space Invaders

The whole fleet shares one offset, so moving 40 drones is one addition followed by placeFleet(). When the leftmost or rightmost surviving drone reaches an edge, the fleet reverses and drops. The speed grows with the wave number and with the share of drones you've destroyed, which gives each wave the same rising tension as the arcade original: the last few drones are fast.

js
    fleet.x += fleet.dir * (40 + wave * 8 + 110 * (1 - alive.length / fleet.total)) * dt;
    placeFleet();
    const minX = Math.min(...alive.map((e) => e.x)), maxX = Math.max(...alive.map((e) => e.x));
    if ((fleet.dir > 0 && maxX > W - 24) || (fleet.dir < 0 && minX < 24)) { fleet.dir = -fleet.dir; fleet.y += fleet.drop; placeFleet(); }
    if (alive.some((e) => e.y + 14 > lineY())) { invaded = true; burst(player.x, player.y, COLORS.ship, 24); gameOver(); return; }

Only the lowest drone in each column fires, picked at random, so enemy shots never pass through other drones. Shots aim slightly toward the player, and the delay between them shrinks every wave until it reaches 0.35 seconds on average.

6. Homing missiles

From wave 3, three shots in ten are slow missiles that chase the ship for 2.5 seconds. Each step, the missile compares the angle it's flying at with the angle to the player and turns toward it by at most 2 radians per second. The atan2(sin, cos) trick wraps the difference into the range from minus pi to pi, so the missile always turns the short way round. A limited turn rate is what makes missiles fair: you can dodge one by moving sideways at the last moment, or shoot it down for 15 points.

js
    if (s.fuel > 0) {
      s.fuel -= dt;
      const have = Math.atan2(s.vy, s.vx), want = Math.atan2(player.y - s.y, player.x - s.x);
      const diff = Math.atan2(Math.sin(want - have), Math.cos(want - have));   // shortest way round
      const turn = have + Math.max(-2 * dt, Math.min(2 * dt, diff)), speed = Math.hypot(s.vx, s.vy);
      s.vx = Math.cos(turn) * speed;
      s.vy = Math.sin(turn) * speed;
    }

The same dozen lines are the core of any missile game in HTML5: change the turn rate, the fuel and the speed and you get anything from lazy rockets to heat seekers.

7. Rocks that split, Asteroids-style

If you want to create an HTML5 game like Asteroids, the splitting rock is the piece to borrow. A large rock breaks into two medium rocks flying apart, a medium rock into two small ones, and a small one disappears. Smaller rocks are worth more because they're harder to hit. Each rock also gets a random lumpy outline when it spawns, which is drawn as a polygon and rotated a little every frame.

js
function breakRock(k) {
  k.dead = true;
  score += [0, 40, 30, 20][k.size];
  burst(k.x, k.y, COLORS.rock, 10);
  if (k.size > 1) { spawnRock(k.size - 1, k.x, k.y, -1); spawnRock(k.size - 1, k.x, k.y, 1); }
}

8. Collisions without tunnelling

Everything collides as circles. hit() compares squared distances, so there's no square root per check. Bullets move 640 units a second, which is about 5 units per fixed step, and the smallest target radius is 9 units, so a bullet can't skip over a target between two steps. With a variable timestep, a slow frame could move a bullet 40 units at once and it would pass through drones. The player's own hitbox is 70% of its drawn size, a common trick that makes near misses feel fair.

js
  for (const b of bullets) {
    if (b.dead) continue;
    const e = alive.find((e) => e.alive && hit(b, e, 17));
    if (e) { e.alive = false; b.dead = true; score += 10 * (fleet.rows - e.r); burst(e.x, e.y, COLORS.rows[e.r], 14); continue; }
    const k = rocks.find((k) => !k.dead && hit(b, k, k.r + 3));
    if (k) { breakRock(k); b.dead = true; continue; }
    const m = shots.find((s) => !s.dead && s.missile && hit(b, s, 9));
    if (m) { m.dead = true; b.dead = true; score += 15; burst(m.x, m.y, COLORS.missile, 8); }
  }

Objects are marked dead during the checks and removed with filter() afterwards. Removing items from an array while looping over it is a classic source of skipped bullets.

9. Lives, score and game over

A hit costs a life and gives two seconds of blinking invulnerability. The game ends when you run out of lives or when any drone crosses the red line. Drones in the top row are worth the most, and clearing a wave adds 100 points per wave number. The best score is kept for the session and shown on the game over screen.

Make it work on phones

Touch control is relative dragging: put a finger anywhere and the ship moves as far as your finger does, so your thumb never covers the ship. While the finger is down, the ship fires automatically. Pointer events give mouse and touch the same code path, so on a desktop you can drag with the mouse button held.

js
canvas.addEventListener("pointermove", (e) => {
  if (!drag || e.pointerId !== drag.id) return;
  // Relative drag: the ship moves as far as the finger, so the finger never hides it.
  player.x += (e.clientX - drag.x) / scale;
  player.y += (e.clientY - drag.y) / scale;
  drag.x = e.clientX;
  drag.y = e.clientY;
  clampPlayer();
});

Portrait and landscape both work because of the world units in step 2. If you rotate the phone mid-wave, resize() moves the red line and lifts the fleet if it would suddenly be too close. For more on screen sizes, touch and performance, read our guide to HTML5 mobile game development.

Ideas to extend it

  • Turn it into an avoider game: remove the fleet and bullets, spawn rocks faster every 10 seconds and score the time survived. An avoider is a good first HTML5 game tutorial on its own.
  • Add power-ups that drop from drones: a triple shot, a shield or a slower fleet for 8 seconds.
  • Give every fifth wave a boss with a health bar and a sweeping laser.
  • Let some drones break formation and dive at the ship, as later arcade shooters did.
  • Add sound with the Web Audio API: a short square-wave blip for each shot and a noise burst for explosions, started after the first input.
  • Add gamepad support with the Gamepad API, reading the left stick in update().

From prototype to a game you can sell

This file is a good way to learn and a fine base for a jam entry. A shooter that players pay for, or that a portal accepts, needs a lot more around the same loop:

This shooter fileA production HTML5 game
ContentEndless waves of one enemy typeMany enemies, bosses, modes, upgrades and progression
Art and audioShapes, no soundOriginal art, animation, music and effects
SavesScore lost on reloadProgress, unlocks and settings saved per player
InputKeyboard, mouse and touchPlus gamepad, remapping and assist options
PlatformsDesktop and phone browsersPortal SDK hooks, ads, app wrappers, localisation
QualityChecked by a test scriptUnit tests and cross-browser end-to-end tests
Code and docsOne scriptA typed project, data files for tuning, a quick start
LicenceMITA commercial licence for your product

Our action games fill that gap. Neon Survivor is a roguelite auto-shooter where you survive enemy swarms and pick one of three upgrades at each level-up, and Mech Arena Lite has you fight waves with a modular mech. Every game ships as a buildable TypeScript project with its tuning in JSON data files, so you can reskin it and launch instead of rebuilding a prototype:

You can also browse all our action games, or try the Breakout tutorial for another arcade game built the same way.

The complete code

One file, no libraries. Save it as space-shooter.html and open it in any browser. Free to use under the MIT license: copy it, change it, ship it.

space-shooter.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Nova Patrol: a space shooter in one HTML file. Free to use, change and ship (MIT license). -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nova Patrol (HTML5 canvas shooter)</title>
<style>
  html, body { margin: 0; height: 100%; background: #05060f; color: #eef2ff; font: 16px system-ui, sans-serif; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Settings -----
const VIEW = 480;                // world units along the short side of the screen
const STEP = 1 / 120;            // fixed simulation step in seconds
const PLAYER_SPEED = 300;        // world units per second
const FIRE_DELAY = 0.18;         // seconds between player shots
const BULLET_SPEED = 640;        // world units per second
const LIVES = 3;
const GAP_X = 52, GAP_Y = 36;    // spacing of the drone fleet
const COLORS = { bg: "#05060f", ship: "#6ee7ff", flame: "#ffb347", bullet: "#fffbe0", shot: "#ff5f7e", missile: "#ffa63d", rock: "#8d8599", line: "rgba(255, 95, 126, 0.5)", text: "#eef2ff", rows: ["#ff6bd6", "#ffb84d", "#9dff6b", "#6bb0ff", "#c08bff"] };

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let W = VIEW, H = VIEW, scale = 1;   // world size and world-to-CSS-pixel scale, set by resize()

// ----- Game state -----
let player, bullets, enemies, fleet, shots, rocks, sparks, state, score, best = 0, lives, wave;
let banner = 0, fireTimer = 0, rockTimer = 0, overAt = 0, invaded = false;
const stars = Array.from({ length: 80 }, () => ({ x: Math.random(), y: Math.random(), s: 0.02 + Math.random() * 0.08 }));
const lineY = () => H - 150;          // the fleet must not cross this line
const hit = (a, b, r) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r;   // circle overlap, no square root

function reset() {
  player = { x: W / 2, y: H - 60, r: 14, cool: 0, inv: 0 };
  bullets = []; rocks = []; sparks = [];
  score = 0; lives = LIVES; wave = 0; invaded = false;
  nextWave();
  state = "ready";                    // ready → playing → over
}

function begin() {
  if (state === "ready") state = "playing";
  else if (state === "over" && performance.now() - overAt > 600) { reset(); state = "playing"; }
}

// ----- Waves: a fleet of drones that sweeps sideways and steps down -----
function nextWave() {
  wave++;
  const rowsN = Math.min(5, 2 + wave), colsN = Math.max(4, Math.min(8, Math.floor((W - 80) / GAP_X)));
  enemies = [];
  for (let r = 0; r < rowsN; r++) for (let c = 0; c < colsN; c++) enemies.push({ r, c, x: 0, y: 0, alive: true });
  // Step-down size: about 2,700 units of sweeping before a full fleet lands, on any screen shape.
  // Wide screens get long sweeps and small steps; tall phones get short sweeps and bigger steps.
  const sweep = W - 48 - (colsN - 1) * GAP_X, passes = Math.max(8, Math.round(2700 / sweep));
  const bottom = 64 + (rowsN - 1) * GAP_Y + 14;
  fleet = { x: (W - (colsN - 1) * GAP_X) / 2, y: 64, dir: 1, rows: rowsN, total: rowsN * colsN, drop: Math.max(6, (lineY() - bottom) / passes) };
  placeFleet();
  shots = [];
  fireTimer = 1.2;
  rockTimer = 2.5;
  banner = 1.6;
}

function placeFleet() {
  for (const e of enemies) { e.x = fleet.x + e.c * GAP_X; e.y = fleet.y + e.r * GAP_Y; }
}

// Only the lowest drone in each column fires, so shots never pass through other drones.
function enemyFire(alive) {
  const front = alive.filter((e) => !alive.some((o) => o.c === e.c && o.r > e.r));
  const e = front[Math.floor(Math.random() * front.length)];
  if (wave >= 3 && Math.random() < 0.3) {
    shots.push({ x: e.x, y: e.y + 12, vx: 0, vy: 150, missile: true, fuel: 2.5, r: 6 });   // a slow homing missile
  } else {
    const vx = Math.max(-70, Math.min(70, (player.x - e.x) * 0.3));           // aimed a little at the player
    shots.push({ x: e.x, y: e.y + 12, vx, vy: 210 + wave * 12, missile: false, fuel: 0, r: 4 });
  }
}

function spawnRock(size, x, y, side = 0) {
  const shape = Array.from({ length: 9 }, () => 0.75 + Math.random() * 0.35);  // a lumpy outline
  rocks.push({ x, y, size, r: [0, 10, 16, 24][size], shape, a: 0, spin: (Math.random() - 0.5) * 3,
    vx: side ? side * (50 + Math.random() * 40) : (Math.random() - 0.5) * 60, vy: 60 + Math.random() * 50 + wave * 6 });
}

// A rock splits in two until it is small, like the classic Asteroids arcade game.
function breakRock(k) {
  k.dead = true;
  score += [0, 40, 30, 20][k.size];
  burst(k.x, k.y, COLORS.rock, 10);
  if (k.size > 1) { spawnRock(k.size - 1, k.x, k.y, -1); spawnRock(k.size - 1, k.x, k.y, 1); }
}

function burst(x, y, color, n) {
  for (let i = 0; i < n && sparks.length < 300; i++) {
    const a = Math.random() * Math.PI * 2, v = 40 + Math.random() * 160;
    sparks.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, t: 0.6, color });
  }
}

function hurt() {
  burst(player.x, player.y, COLORS.ship, 24);
  lives--;
  player.inv = 2;                     // two seconds of blinking safety
  if (lives <= 0) gameOver();
}

function gameOver() {
  state = "over";
  overAt = performance.now();
  best = Math.max(best, score);
}

function clampPlayer() {
  player.x = Math.max(16, Math.min(W - 16, player.x));
  player.y = Math.max(lineY() + 30, Math.min(H - 24, player.y));
}

// ----- One fixed step of the simulation -----
function update(dt) {
  // Player: move, then fire while Space is held or a finger is down.
  let mx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0), my = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
  if (mx && my) { mx *= Math.SQRT1_2; my *= Math.SQRT1_2; }
  player.x += mx * PLAYER_SPEED * dt;
  player.y += my * PLAYER_SPEED * dt;
  clampPlayer();
  player.cool -= dt;
  player.inv -= dt;
  if ((keys.fire || drag) && player.cool <= 0) { bullets.push({ x: player.x, y: player.y - 18 }); player.cool = FIRE_DELAY; }
  for (const b of bullets) { b.y -= BULLET_SPEED * dt; if (b.y < -10) b.dead = true; }

  const alive = enemies.filter((e) => e.alive);
  if (banner > 0) banner -= dt;       // the fleet waits while "Wave N" shows
  else {
    // The fewer drones are left, the faster the fleet sweeps.
    fleet.x += fleet.dir * (40 + wave * 8 + 110 * (1 - alive.length / fleet.total)) * dt;
    placeFleet();
    const minX = Math.min(...alive.map((e) => e.x)), maxX = Math.max(...alive.map((e) => e.x));
    if ((fleet.dir > 0 && maxX > W - 24) || (fleet.dir < 0 && minX < 24)) { fleet.dir = -fleet.dir; fleet.y += fleet.drop; placeFleet(); }
    if (alive.some((e) => e.y + 14 > lineY())) { invaded = true; burst(player.x, player.y, COLORS.ship, 24); gameOver(); return; }
    fireTimer -= dt;
    if (fireTimer <= 0) { fireTimer = Math.max(0.35, 1.3 - wave * 0.1) * (0.6 + Math.random() * 0.8); enemyFire(alive); }
    rockTimer -= dt;
    if (rockTimer <= 0) { rockTimer = Math.max(1.4, 4.4 - wave * 0.4) * (0.7 + Math.random() * 0.6); spawnRock(3, 30 + Math.random() * (W - 60), -30); }
  }

  // Enemy shots; missiles turn toward the player at a limited rate until their fuel runs out.
  for (const s of shots) {
    if (s.fuel > 0) {
      s.fuel -= dt;
      const have = Math.atan2(s.vy, s.vx), want = Math.atan2(player.y - s.y, player.x - s.x);
      const diff = Math.atan2(Math.sin(want - have), Math.cos(want - have));   // shortest way round
      const turn = have + Math.max(-2 * dt, Math.min(2 * dt, diff)), speed = Math.hypot(s.vx, s.vy);
      s.vx = Math.cos(turn) * speed;
      s.vy = Math.sin(turn) * speed;
    }
    s.x += s.vx * dt;
    s.y += s.vy * dt;
    if (s.y > H + 20 || s.y < -40 || s.x < -20 || s.x > W + 20) s.dead = true;
  }
  for (const k of rocks) {
    k.x += k.vx * dt; k.y += k.vy * dt; k.a += k.spin * dt;
    if (k.x < k.r || k.x > W - k.r) k.vx = Math.sign(W / 2 - k.x) * Math.abs(k.vx);   // bounce off the sides
    if (k.y > H + k.r) k.dead = true;
  }

  // Collisions. Each step moves a bullet about 5 units, less than any radius, so nothing tunnels.
  for (const b of bullets) {
    if (b.dead) continue;
    const e = alive.find((e) => e.alive && hit(b, e, 17));
    if (e) { e.alive = false; b.dead = true; score += 10 * (fleet.rows - e.r); burst(e.x, e.y, COLORS.rows[e.r], 14); continue; }
    const k = rocks.find((k) => !k.dead && hit(b, k, k.r + 3));
    if (k) { breakRock(k); b.dead = true; continue; }
    const m = shots.find((s) => !s.dead && s.missile && hit(b, s, 9));
    if (m) { m.dead = true; b.dead = true; score += 15; burst(m.x, m.y, COLORS.missile, 8); }
  }
  if (player.inv <= 0) {
    const s = shots.find((s) => !s.dead && hit(s, player, s.r + player.r * 0.7));
    const k = rocks.find((k) => !k.dead && hit(k, player, k.r * 0.85 + player.r * 0.7));
    if (s) s.dead = true;
    if (k) { k.dead = true; burst(k.x, k.y, COLORS.rock, 10); }
    if (s || k) hurt();
  }
  bullets = bullets.filter((b) => !b.dead);
  shots = shots.filter((s) => !s.dead);
  rocks = rocks.filter((k) => !k.dead);
  if (state === "playing" && !enemies.some((e) => e.alive)) { score += wave * 100; nextWave(); }
}

function moveSparks(dt) {
  for (const p of sparks) { p.x += p.vx * dt; p.y += p.vy * dt; p.t -= dt; }
  sparks = sparks.filter((p) => p.t > 0);
}

// ----- Sizing: fill the window, keep 480 world units on the short side, sharp on high-DPI -----
function resize() {
  const dpr = window.devicePixelRatio || 1;
  canvas.style.width = window.innerWidth + "px";
  canvas.style.height = window.innerHeight + "px";
  canvas.width = Math.round(window.innerWidth * dpr);
  canvas.height = Math.round(window.innerHeight * dpr);
  scale = Math.min(window.innerWidth, window.innerHeight) / VIEW;
  W = window.innerWidth / scale;
  H = window.innerHeight / scale;
  if (player) {
    clampPlayer();
    // Turning a phone sideways mid-wave moves the line up: lift the fleet so the game stays fair.
    fleet.y = Math.max(64, Math.min(fleet.y, lineY() - 14 - (fleet.rows - 1) * GAP_Y - 4 * fleet.drop));
    placeFleet();
  }
  draw();
}

// ----- Drawing -----
function drawDrone(e, t) {
  const flap = Math.sin(t * 6 + e.c) * 3;
  ctx.fillStyle = COLORS.rows[e.r];
  ctx.beginPath();                    // body: a squat hexagon
  ctx.moveTo(e.x - 14, e.y); ctx.lineTo(e.x - 7, e.y - 10); ctx.lineTo(e.x + 7, e.y - 10);
  ctx.lineTo(e.x + 14, e.y); ctx.lineTo(e.x + 7, e.y + 9); ctx.lineTo(e.x - 7, e.y + 9);
  ctx.fill();
  ctx.fillRect(e.x - 19, e.y - 4 + flap, 5, 8);   // fins
  ctx.fillRect(e.x + 14, e.y - 4 - flap, 5, 8);
  ctx.fillStyle = COLORS.bg;
  ctx.beginPath(); ctx.arc(e.x, e.y - 1, 4, 0, Math.PI * 2); ctx.fill();   // eye
}

function drawShip(x, y, s) {
  ctx.beginPath();
  ctx.moveTo(x, y - 18 * s); ctx.lineTo(x + 14 * s, y + 12 * s); ctx.lineTo(x, y + 6 * s); ctx.lineTo(x - 14 * s, y + 12 * s);
  ctx.closePath();
  ctx.fill();
}

function draw() {
  const dpr = window.devicePixelRatio || 1, t = performance.now() / 1000;
  ctx.setTransform(dpr * scale, 0, 0, dpr * scale, 0, 0);    // draw in world units
  ctx.fillStyle = COLORS.bg;
  ctx.fillRect(0, 0, W, H);
  for (const s of stars) { ctx.fillStyle = `rgba(220, 230, 255, ${0.3 + s.s * 6})`; ctx.fillRect(s.x * W, s.y * H, s.s * 25, s.s * 25); }
  if (!player) return;

  ctx.strokeStyle = COLORS.line;      // the line the fleet must not cross
  ctx.setLineDash([8, 10]);
  ctx.beginPath(); ctx.moveTo(0, lineY()); ctx.lineTo(W, lineY()); ctx.stroke();
  ctx.setLineDash([]);

  for (const e of enemies) if (e.alive) drawDrone(e, t);
  for (const k of rocks) {
    ctx.fillStyle = COLORS.rock;
    ctx.beginPath();
    k.shape.forEach((m, i) => { const a = k.a + (i / k.shape.length) * Math.PI * 2; ctx.lineTo(k.x + Math.cos(a) * k.r * m, k.y + Math.sin(a) * k.r * m); });
    ctx.fill();
  }
  ctx.fillStyle = COLORS.bullet;
  for (const b of bullets) ctx.fillRect(b.x - 1.5, b.y - 7, 3, 14);
  for (const s of shots) {
    ctx.fillStyle = s.missile ? COLORS.missile : COLORS.shot;
    ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
    if (s.missile) { ctx.fillStyle = "rgba(255, 166, 61, 0.35)"; ctx.beginPath(); ctx.arc(s.x - s.vx * 0.06, s.y - s.vy * 0.06, 4, 0, Math.PI * 2); ctx.fill(); }
  }
  for (const p of sparks) { ctx.globalAlpha = p.t / 0.6; ctx.fillStyle = p.color; ctx.fillRect(p.x - 2, p.y - 2, 4, 4); }
  ctx.globalAlpha = 1;

  // The ship blinks while it is invulnerable and disappears once the game is over.
  if (state !== "over" && (player.inv <= 0 || Math.floor(t * 10) % 2)) {
    ctx.fillStyle = COLORS.flame;
    ctx.beginPath(); ctx.moveTo(player.x - 5, player.y + 9); ctx.lineTo(player.x + 5, player.y + 9); ctx.lineTo(player.x, player.y + 16 + Math.random() * 8); ctx.fill();
    ctx.fillStyle = COLORS.ship;
    drawShip(player.x, player.y, 1);
  }

  // Score bar, in CSS pixels so the text stays the same size on every screen.
  const sw = window.innerWidth, sh = window.innerHeight;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = COLORS.text;
  ctx.font = "600 16px system-ui, sans-serif";
  ctx.textBaseline = "middle";
  ctx.textAlign = "left";
  ctx.fillText(`Score ${score}`, 12, 20);
  ctx.textAlign = "center";
  ctx.fillText(`Wave ${wave}`, sw / 2, 20);
  ctx.fillStyle = COLORS.ship;
  for (let i = 0; i < lives; i++) drawShip(sw - 18 - i * 22, 22, 0.55);

  const size = Math.min(sw, sh);
  const title = (text, y) => { ctx.fillStyle = COLORS.text; ctx.font = `800 ${Math.max(28, size * 0.08)}px system-ui, sans-serif`; ctx.fillText(text, sw / 2, y); };
  const lines = (list, y) => { ctx.font = `500 ${Math.max(14, size * 0.032)}px system-ui, sans-serif`; list.forEach((l, i) => ctx.fillText(l, sw / 2, y + i * Math.max(22, size * 0.05))); };
  ctx.textAlign = "center";
  if (state === "playing" && banner > 0) title(`Wave ${wave}`, sh * 0.42);
  if (state !== "playing") { ctx.fillStyle = "rgba(5, 6, 15, 0.7)"; ctx.fillRect(0, 40, sw, sh - 40); }
  if (state === "ready") {
    title("Nova Patrol", sh * 0.4);
    lines(["Stop the fleet before it crosses the red line", "Shoot rocks, dodge shots and missiles", "Arrows / WASD move, Space fires", "Phone: drag to move, hold to fire", "Press Space or tap to start"], sh * 0.4 + size * 0.1);
  } else if (state === "over") {
    title("Game over", sh * 0.36);
    lines([invaded ? "The fleet crossed the line" : `You reached wave ${wave}`, `Score ${score} · Best ${best}`, "Tap or press Space to play again"], sh * 0.36 + size * 0.1);
  }
}

// ----- Main loop: fixed steps, smooth frames -----
let last = 0, acc = 0;
function loop(now) {
  const dt = Math.min(0.25, (now - last) / 1000);
  last = now;
  for (const s of stars) { s.y += s.s * dt * (state === "playing" ? 1.5 : 0.4); if (s.y > 1) { s.y -= 1; s.x = Math.random(); } }
  if (state === "playing") {
    acc += dt;
    while (acc >= STEP && state === "playing") { update(STEP); acc -= STEP; }
  }
  moveSparks(dt);
  draw();
  requestAnimationFrame(loop);
}

// ----- Input: keyboard, plus drag-to-move and hold-to-fire for touch and mouse -----
const keys = {};
const BIND = { ArrowLeft: "left", KeyA: "left", ArrowRight: "right", KeyD: "right", ArrowUp: "up", KeyW: "up", ArrowDown: "down", KeyS: "down", Space: "fire", KeyZ: "fire", KeyJ: "fire" };
window.addEventListener("keydown", (e) => {
  if (BIND[e.code] || e.code === "Enter") e.preventDefault();
  if (BIND[e.code]) keys[BIND[e.code]] = true;
  if ((e.code === "Space" || e.code === "Enter") && !e.repeat) begin();
});
window.addEventListener("keyup", (e) => { if (BIND[e.code]) keys[BIND[e.code]] = false; });

let drag = null;                      // the finger (or mouse button) that steers the ship
canvas.addEventListener("pointerdown", (e) => {
  begin();
  if (state === "playing" && !drag) drag = { id: e.pointerId, x: e.clientX, y: e.clientY };
});
canvas.addEventListener("pointermove", (e) => {
  if (!drag || e.pointerId !== drag.id) return;
  // Relative drag: the ship moves as far as the finger, so the finger never hides it.
  player.x += (e.clientX - drag.x) / scale;
  player.y += (e.clientY - drag.y) / scale;
  drag.x = e.clientX;
  drag.y = e.clientY;
  clampPlayer();
});
const release = (e) => { if (drag && e.pointerId === drag.id) drag = null; };
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
window.addEventListener("blur", () => { for (const k in keys) keys[k] = false; drag = null; });

window.addEventListener("resize", resize);
resize();
reset();
requestAnimationFrame((t) => { last = t; loop(t); });
</script>
</body>
</html>

Frequently asked questions

Is this space shooter code free to use?

Yes. The file is MIT-licensed: you can copy it, change it and use it in personal or commercial projects. Keep the licence line at the top of the file.

Is there an MDN canvas tutorial for Space Invaders?

MDN's best-known canvas game tutorial builds Breakout in pure JavaScript, not Space Invaders. The ideas carry straight over: a canvas, a loop, keyboard input and collision checks. This page applies them to a Space Invaders-style fleet, with Asteroids-style rocks added.

Can I download the source as a zip?

There's nothing to zip: the whole game is one file. Press Download .html above and you have the complete source, ready to open or upload.

Can I make an FPS game in HTML5 the same way?

Not with the 2D canvas. A first-person shooter needs 3D rendering, which in a browser means WebGL or WebGPU, usually through a library such as Three.js. The game loop, input handling and fixed timestep from this tutorial still apply. Our guide to 3D HTML5 games covers where to start.

How do I add sound or a high score that survives a reload?

For sound, create an AudioContext after the first key press or tap and play short tones when the player fires or a drone explodes. For the high score, save best with localStorage.setItem in gameOver() and read it at startup, inside try/catch, because sandboxed frames like the player on this page block storage.

We make and sell HTML5 game source code; links to our games are marked as ours.