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.

Tower Defense in HTML5: Full Game Code and Tutorial

This tower defense game is one HTML file of about 350 lines: a winding path on a 10 × 10 grid, ten waves of three enemy types, three towers (fast darts, splash cannons and slowing frost) with upgrades, plus gold, lives and win or lose screens. Copy the code, save it as tower-defense.html and open it in a browser. It's MIT-licensed.

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

Grove Guard is a small but complete tower defense game: creeps walk a winding path across a grid, you spend gold on towers beside it, and every creep that gets through costs lives. Survive ten waves to win. This HTML5 tower defense game tutorial (or tower defence, if you spell it the British way) covers each system in order: the path, waves, movement, targeting, projectiles, splash and slow effects, building, upgrading, the economy and the end screens.

How to run it

  1. Press Copy code above, or Download .html.
  2. Save it in a new file called tower-defense.html.
  3. Open the file in a browser, tap or press Enter, build a couple of towers and start wave 1.

To try a different map, paste the code into the HTML game code runner and edit the PATH waypoints. The path cells, the enemy route and the build rules all follow from that one array.

HTML5 tower defense game tutorial: how the code works

A tower defense game is several simple systems that run every frame in a fixed order: spawn enemies, move them, let towers pick targets and fire, move shots and apply hits, pay rewards, then check for the end of a wave. Keeping that order inside one update(dt) function makes the game easy to follow and to debug.

1. Towers and enemies are data

Each tower type and enemy type is one line in a table. The game code never mentions "Cannon" or "brute" directly; it reads splash, slow, speed and the rest from these tables, so adding a fourth tower is mostly a new line here.

tower-defense.html
const START_GOLD = 120, START_LIVES = 20, WAVES = 10, MAX_LEVEL = 3;
const TOWERS = [
  { name: "Dart",   info: "fast shots",    cost: 50, range: 2.3, rate: 2.5, damage: 5,  color: "#4fc3f7" },
  { name: "Cannon", info: "area damage",   cost: 90, range: 2.0, rate: 0.7, damage: 20, splash: 1.0, color: "#ffb74d" },
  { name: "Frost",  info: "slows enemies", cost: 70, range: 1.9, rate: 1.5, damage: 3,  slow: 2.0, color: "#b39ddb" },
];
const ENEMIES = {
  runner: { hp: 18, speed: 1.3,  reward: 4,  size: 0.26, color: "#ef5350", lives: 1 },
  scout:  { hp: 10, speed: 2.3,  reward: 4,  size: 0.2,  color: "#ffee58", lives: 1 },
  brute:  { hp: 75, speed: 0.75, reward: 12, size: 0.36, color: "#a1887f", lives: 3 },
};

Ranges and speeds are measured in grid cells, not pixels. That one decision is what lets the same game run on a 390-pixel phone and a 900-pixel laptop: only the drawing code knows how big a cell is.

2. The path: waypoints, segments and blocked cells

The path is a list of corner points. The code turns each pair of corners into a segment with a length, adds the segments up into pathLen, and marks every cell the path crosses so nothing can be built there. pointAt(d) answers the question every enemy asks each frame: if I've walked d cells, where am I?

js
// ----- The path: straight segments, and the set of cells nothing can be built on -----
const segs = [];
let pathLen = 0;
for (let i = 1; i < PATH.length; i++) {
  const [x0, y0] = PATH[i - 1], [x1, y1] = PATH[i];
  const len = Math.hypot(x1 - x0, y1 - y0);
  segs.push({ x0, y0, x1, y1, len });
  pathLen += len;
}
const pathCells = new Set();
for (const s of segs) for (let k = 0; k <= s.len; k++) {
  pathCells.add(`${s.x0 + Math.sign(s.x1 - s.x0) * k},${s.y0 + Math.sign(s.y1 - s.y0) * k}`);
}
// Where is an enemy that has walked d cells? Walk the segments until d runs out.
function pointAt(d) {
  for (const s of segs) {
    if (d <= s.len) return { x: s.x0 + (s.x1 - s.x0) * (d / s.len), y: s.y0 + (s.y1 - s.y0) * (d / s.len) };
    d -= s.len;
  }
  return { x: PATH[PATH.length - 1][0], y: PATH[PATH.length - 1][1] };
}

Storing progress as one number, d, is the key idea. Enemies never need pathfinding, "furthest along" is a simple comparison, and an enemy has escaped when d >= pathLen. The path here is 28 cells long, and the first and last waypoints sit just off the grid so creeps walk in and out instead of appearing on the board.

3. Waves get longer and tougher

Wave n has 6 + 2n enemies, so the first wave has 8 and the last has 26. Scouts mix in from wave 3 and brutes from wave 5, and every enemy's health grows by 33% per wave. Generating waves from a formula keeps the file short; a production game would list each wave by hand.

js
// Wave n: more enemies with more health. Scouts join from wave 3, brutes from wave 5.
function waveList(n) {
  const list = [];
  for (let i = 0; i < 6 + n * 2; i++) list.push(n >= 5 && i % 5 === 4 ? "brute" : n >= 3 && i % 3 === 2 ? "scout" : "runner");
  return list;
}
const hpScale = (n) => Math.pow(1.33, n - 1);  // +33% health per wave

A wave only starts when the player presses Start wave, so there's always a calm moment to build. Enemies leave the queue one at a time, 0.75 seconds apart, or 1.3 seconds after a brute.

4. Moving enemies

Each frame, every enemy adds speed × dt to its distance, at half speed while a frost slow is active, and looks up its new position. An enemy that reaches the end is marked done and costs lives: one for runners and scouts, three for a brute.

js
  // Enemies walk along the path (half speed while slowed). Reaching the end costs lives.
  for (const e of enemies) {
    const k = ENEMIES[e.kind];
    e.slowT = Math.max(0, e.slowT - dt);
    e.d += k.speed * (e.slowT > 0 ? 0.5 : 1) * dt;
    if (e.d >= pathLen) { e.done = true; lives -= k.lives; }
    else Object.assign(e, pointAt(e.d));
  }

5. Targeting: the first enemy in range

Towers in most tower defense games shoot the enemy that is furthest along the path, because that's the one about to cost you lives. With progress stored as d, "first" is a single comparison. The reload timer cd counts down every frame and the tower fires as soon as it reaches zero and has a target.

js
  // Towers aim at the enemy in range that is furthest along the path, and fire when reloaded.
  for (const t of towers) {
    t.cd = Math.max(0, t.cd - dt);
    let target = null;
    for (const e of enemies) {
      if (e.done || e.hp <= 0 || Math.hypot(e.x - t.gx, e.y - t.gy) > range(t)) continue;
      if (!target || e.d > target.d) target = e;
    }
    if (!target) continue;
    t.angle = Math.atan2(target.y - t.gy, target.x - t.gx);
    if (t.cd === 0) { t.cd = 1 / rate(t); shots.push({ x: t.gx, y: t.gy, tower: t, target, tx: target.x, ty: target.y }); }
  }

Checking every tower against every enemy is fine at this size: 20 towers and 26 enemies is about 500 distance checks a frame. A game with hundreds of units would use a spatial grid so each tower only checks nearby cells.

6. Shots, splash and slow

Shots home in on their target at 9 cells a second. If the target dies or escapes first, the shot keeps flying to the last place it saw it, which looks natural and means a cannon ball still explodes there. On impact, hit() applies the tower's effect.

js
function hit(s) {
  const type = TOWERS[s.tower.type], dmg = damage(s.tower);
  if (type.splash) {
    for (const e of enemies) if (!e.done && Math.hypot(e.x - s.tx, e.y - s.ty) <= type.splash) e.hp -= dmg;
    booms.push({ x: s.tx, y: s.ty, r: type.splash, t: 0.25 });
  } else if (!s.target.done && s.target.hp > 0) {
    s.target.hp -= dmg;
    if (type.slow) s.target.slowT = type.slow;
  }
}

Damage only lowers hp. A separate pass then pays the reward and removes each dead enemy exactly once, so a cannon blast that hits an enemy already at zero health can never pay twice.

7. Building, upgrading and the economy

Gold comes from kills and from a bonus of 20 + 5 × wave for each wave cleared. Tapping grass builds the selected tower; tapping an existing tower upgrades it. Each level multiplies damage by 1.6 and adds a little range and fire rate, and the upgrade price rises with the level, so the player keeps choosing between more towers and better ones.

js
// Tower stats grow with level: more damage, a little more range and fire rate.
const damage = (t) => TOWERS[t.type].damage * Math.pow(1.6, t.level - 1);
const range = (t) => TOWERS[t.type].range + 0.3 * (t.level - 1);
const rate = (t) => TOWERS[t.type].rate * (1 + 0.15 * (t.level - 1));
const upgradeCost = (t) => Math.round(TOWERS[t.type].cost * 0.75 * t.level);
const say = (text, t = 1.6) => { toast = { text, t }; };

function buildOrUpgrade(gx, gy) {
  if (pathCells.has(`${gx},${gy}`)) return say("Build on the grass, not the path");
  const t = towers.find((t) => t.gx === gx && t.gy === gy);
  if (t) {
    if (t.level >= MAX_LEVEL) return say("Already at max level");
    const c = upgradeCost(t);
    if (gold < c) return say(`Upgrade costs ${c} gold`);
    gold -= c; t.level++;
    return say(`${TOWERS[t.type].name} upgraded to level ${t.level}`);
  }
  const type = TOWERS[selected];
  if (gold < type.cost) return say(`${type.name} costs ${type.cost} gold`);
  gold -= type.cost;
  towers.push({ gx, gy, type: selected, level: 1, cd: 0, angle: 0 });
}

Every refusal explains itself in a short message. It's a small thing, but "Cannon costs 90 gold" teaches the economy better than a tap that does nothing.

8. Winning and losing

The end checks run last in update(). You lose when lives reach zero. A wave is over when its queue is empty and no enemy is left on the board; clearing the tenth wave wins the game.

js
  if (lives <= 0) { lives = 0; state = "lost"; endedAt = performance.now(); }
  else if (waveActive && !queue.length && !enemies.length) {
    waveActive = false;
    const bonus = 20 + wave * 5;
    gold += bonus;
    if (wave === WAVES) { state = "won"; endedAt = performance.now(); } else say(`Wave ${wave} cleared: +${bonus} gold`);
  }

The Speed ×2 button doesn't change any numbers. The loop simply calls update(dt) twice per frame, so fast-forward behaves exactly like normal speed.

9. Drawing

draw() paints the grid, a range circle under the cursor (for the selected tower on empty grass, or for the tower already there), the towers with a barrel aimed at their target and one dot per level, then the enemies with health bars. Enemies, shots and blasts are drawn inside a clip region the size of the map, so creeps slide in from the left edge and out through the bottom instead of drawing over the shop.

Make it work on phones

Tower defense works well on touch because every action is a tap: choose a tower in the shop, tap grass to build, tap a tower to upgrade, tap Start wave. One pointer event handler covers touch and mouse; the keyboard moves a cursor over the same grid.

js
canvas.addEventListener("pointerdown", (e) => {
  usingKeys = false;
  if (state !== "playing") { begin(); return; }
  const b = buttons.find((b) => e.clientX >= b.x && e.clientX <= b.x + b.w && e.clientY >= b.y && e.clientY <= b.y + b.h);
  if (b) { press(b); return; }
  const c = cellAt(e.clientX, e.clientY);
  if (c.x >= 0 && c.y >= 0 && c.x < COLS && c.y < ROWS) { cursor = c; buildOrUpgrade(c.x, c.y); }
});

When the screen is taller than it is wide, the shop moves under the map as a row of three tower buttons plus wave and speed buttons; on wide screens it becomes a column on the right. On a 390 × 640 phone each cell is 38 pixels, which is just enough to hit reliably. There's no hover on touch, so the tapped cell keeps the cursor and shows the tower's range after you build. Our guide to HTML5 mobile game development covers touch targets and layouts in more detail.

How to make a TD game in HTML5 that's fun: balancing

The code is the easy half; tuning is where a tower defense game is won or lost. We balanced this one by letting scripted players run all ten waves. With good tower spots and upgrades, a mixed defence won with 14 of 20 lives left. The same towers without upgrades fell on wave 10, and three towers placed in the corners, far from the path, lost on wave 3. A darts-only defence did at least as well as the mixed one, which tells you darts are slightly too efficient: a good next step would be armour that blocks small hits, so cannons earn their price against brutes.

The levers to try, in order of impact: the health growth in hpScale, the starting gold, tower cost against damage per second, and the length of the path. A longer path gives every tower more time to shoot, which makes the whole game easier.

Ideas to extend it

  • Add a sell button that refunds 70% of what a tower cost.
  • Let players choose targeting per tower: first, last, strongest or closest.
  • Add a boss to the final wave with its own health bar at the top of the screen.
  • Make several maps by giving each its own PATH and a level select screen.
  • Let towers block the route and recompute paths with a flow field, as explained in Red Blob Games' tower defense pathfinding article.
  • Add sound for shots and explosions with the Web Audio API, starting it after the first tap.

From prototype to a game you can sell

This file covers the core loop. A commercial tower defense game is mostly the content and polish around it:

This tower defense fileA production tower defense game
ContentOne map, ten generated wavesA campaign of hand-built levels, bosses, challenge modes
TowersThree types, three levelsMany towers, branching upgrades, combinations
Art and audioCircles and lines, no soundOriginal art, animation, music and effects
SavesNothing kept on reloadLevel progress, stars and settings saved
BalanceTuned with a few scripted runsData files, tested difficulty curves
PlatformsDesktop and phone browsersPortal SDK hooks, ads, localisation, gamepad
LicenceMITA commercial licence for your product

Our two tower defense games are built for that second column. Pocket Tower Defense defends compact lanes with towers you can reposition and combine for elemental effects, with bosses and a campaign. Train Defense turns the idea around: you mount and rearrange weapons on train carriages while crossing enemy territory. Both are built on Phaser 4.2.1 and ship as TypeScript projects with their waves and economy in JSON data files.

See more in our strategy games, or read our guide to making an HTML5 strategy game. For other free code, browse the HTML game code collection; the maze game is another grid-based project.

The complete code

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

tower-defense.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Grove Guard: a complete tower defense game 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>Grove Guard (HTML5 tower defense)</title>
<style>
  html, body { margin: 0; height: 100%; background: #13201a; color: #eef5e9; 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 COLS = 10, ROWS = 10;
// Waypoints in grid cells. Enemies enter off the left edge and leave off the bottom.
const PATH = [[-1, 1], [7, 1], [7, 4], [2, 4], [2, 7], [8, 7], [8, 10]];
const START_GOLD = 120, START_LIVES = 20, WAVES = 10, MAX_LEVEL = 3;
const TOWERS = [
  { name: "Dart",   info: "fast shots",    cost: 50, range: 2.3, rate: 2.5, damage: 5,  color: "#4fc3f7" },
  { name: "Cannon", info: "area damage",   cost: 90, range: 2.0, rate: 0.7, damage: 20, splash: 1.0, color: "#ffb74d" },
  { name: "Frost",  info: "slows enemies", cost: 70, range: 1.9, rate: 1.5, damage: 3,  slow: 2.0, color: "#b39ddb" },
];
const ENEMIES = {
  runner: { hp: 18, speed: 1.3,  reward: 4,  size: 0.26, color: "#ef5350", lives: 1 },
  scout:  { hp: 10, speed: 2.3,  reward: 4,  size: 0.2,  color: "#ffee58", lives: 1 },
  brute:  { hp: 75, speed: 0.75, reward: 12, size: 0.36, color: "#a1887f", lives: 3 },
};
// Wave n: more enemies with more health. Scouts join from wave 3, brutes from wave 5.
function waveList(n) {
  const list = [];
  for (let i = 0; i < 6 + n * 2; i++) list.push(n >= 5 && i % 5 === 4 ? "brute" : n >= 3 && i % 3 === 2 ? "scout" : "runner");
  return list;
}
const hpScale = (n) => Math.pow(1.33, n - 1);  // +33% health per wave

// ----- The path: straight segments, and the set of cells nothing can be built on -----
const segs = [];
let pathLen = 0;
for (let i = 1; i < PATH.length; i++) {
  const [x0, y0] = PATH[i - 1], [x1, y1] = PATH[i];
  const len = Math.hypot(x1 - x0, y1 - y0);
  segs.push({ x0, y0, x1, y1, len });
  pathLen += len;
}
const pathCells = new Set();
for (const s of segs) for (let k = 0; k <= s.len; k++) {
  pathCells.add(`${s.x0 + Math.sign(s.x1 - s.x0) * k},${s.y0 + Math.sign(s.y1 - s.y0) * k}`);
}
// Where is an enemy that has walked d cells? Walk the segments until d runs out.
function pointAt(d) {
  for (const s of segs) {
    if (d <= s.len) return { x: s.x0 + (s.x1 - s.x0) * (d / s.len), y: s.y0 + (s.y1 - s.y0) * (d / s.len) };
    d -= s.len;
  }
  return { x: PATH[PATH.length - 1][0], y: PATH[PATH.length - 1][1] };
}

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let W = 0, H = 0, cell = 40, ox = 0, oy = 0, buttons = [];

// ----- Sizing: map on the left and shop on the right when wide, shop below when tall -----
function resize() {
  const dpr = window.devicePixelRatio || 1;
  W = window.innerWidth; H = window.innerHeight;
  canvas.style.width = W + "px"; canvas.style.height = H + "px";
  canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  const hud = 44, wide = W > H * 1.1;
  cell = Math.max(16, Math.floor(wide ? Math.min((W - 230) / COLS, (H - hud - 8) / ROWS) : Math.min((W - 8) / COLS, (H - hud - 170) / ROWS)));
  ox = wide ? 8 : (W - cell * COLS) / 2; oy = hud;
  const p = wide ? { x: ox + cell * COLS + 12, y: hud, w: W - ox - cell * COLS - 22, h: H - hud - 10 }
                 : { x: 8, y: oy + cell * ROWS + 10, w: W - 16, h: H - oy - cell * ROWS - 18 };
  const g = 8;
  if (wide) {
    const bh = Math.min(76, (p.h - g * 4) / 5);
    buttons = [0, 1, 2, "wave", "speed"].map((id, i) => ({ id, x: p.x, y: p.y + i * (bh + g), w: p.w, h: bh }));
  } else {
    const bw = (p.w - g * 2) / 3, h1 = Math.min(90, p.h * 0.55), h2 = Math.min(64, p.h - h1 - g);
    buttons = [0, 1, 2].map((id) => ({ id, x: p.x + id * (bw + g), y: p.y, w: bw, h: h1 }));
    buttons.push({ id: "wave", x: p.x, y: p.y + h1 + g, w: bw * 2 + g, h: h2 }, { id: "speed", x: p.x + (bw + g) * 2, y: p.y + h1 + g, w: bw, h: h2 });
  }
}

// ----- Game state -----
let state = "ready";                 // ready → playing → won | lost
let gold, lives, wave, waveActive, queue, spawnTimer, towers, enemies, shots, booms;
let selected = 0, speed = 1, cursor = { x: 4, y: 2 }, usingKeys = false, toast = null, endedAt = 0, last = 0;

function begin() {
  if (state !== "ready" && performance.now() - endedAt < 800) return;
  gold = START_GOLD; lives = START_LIVES; wave = 0; waveActive = false;
  queue = []; spawnTimer = 0; towers = []; enemies = []; shots = []; booms = [];
  state = "playing";
  say("Build a tower or two, then start wave 1", 3);
}

// Tower stats grow with level: more damage, a little more range and fire rate.
const damage = (t) => TOWERS[t.type].damage * Math.pow(1.6, t.level - 1);
const range = (t) => TOWERS[t.type].range + 0.3 * (t.level - 1);
const rate = (t) => TOWERS[t.type].rate * (1 + 0.15 * (t.level - 1));
const upgradeCost = (t) => Math.round(TOWERS[t.type].cost * 0.75 * t.level);
const say = (text, t = 1.6) => { toast = { text, t }; };

function buildOrUpgrade(gx, gy) {
  if (pathCells.has(`${gx},${gy}`)) return say("Build on the grass, not the path");
  const t = towers.find((t) => t.gx === gx && t.gy === gy);
  if (t) {
    if (t.level >= MAX_LEVEL) return say("Already at max level");
    const c = upgradeCost(t);
    if (gold < c) return say(`Upgrade costs ${c} gold`);
    gold -= c; t.level++;
    return say(`${TOWERS[t.type].name} upgraded to level ${t.level}`);
  }
  const type = TOWERS[selected];
  if (gold < type.cost) return say(`${type.name} costs ${type.cost} gold`);
  gold -= type.cost;
  towers.push({ gx, gy, type: selected, level: 1, cd: 0, angle: 0 });
}

function nextWave() {
  if (waveActive || wave >= WAVES) return;
  wave++;
  queue = waveList(wave);
  waveActive = true; spawnTimer = 0;
}

// ----- Update -----
function update(dt) {
  if (state !== "playing") return;
  // Spawn the next enemy of the wave when the timer runs out.
  if (waveActive && queue.length && (spawnTimer -= dt) <= 0) {
    const kind = queue.shift(), hp = ENEMIES[kind].hp * hpScale(wave);
    enemies.push({ kind, d: 0, hp, maxHp: hp, slowT: 0, ...pointAt(0) });
    spawnTimer = kind === "brute" ? 1.3 : 0.75;
  }
  // Enemies walk along the path (half speed while slowed). Reaching the end costs lives.
  for (const e of enemies) {
    const k = ENEMIES[e.kind];
    e.slowT = Math.max(0, e.slowT - dt);
    e.d += k.speed * (e.slowT > 0 ? 0.5 : 1) * dt;
    if (e.d >= pathLen) { e.done = true; lives -= k.lives; }
    else Object.assign(e, pointAt(e.d));
  }
  // Towers aim at the enemy in range that is furthest along the path, and fire when reloaded.
  for (const t of towers) {
    t.cd = Math.max(0, t.cd - dt);
    let target = null;
    for (const e of enemies) {
      if (e.done || e.hp <= 0 || Math.hypot(e.x - t.gx, e.y - t.gy) > range(t)) continue;
      if (!target || e.d > target.d) target = e;
    }
    if (!target) continue;
    t.angle = Math.atan2(target.y - t.gy, target.x - t.gx);
    if (t.cd === 0) { t.cd = 1 / rate(t); shots.push({ x: t.gx, y: t.gy, tower: t, target, tx: target.x, ty: target.y }); }
  }
  // Shots home in at 9 cells a second. If the target dies first, they land where it was.
  for (const s of shots) {
    if (!s.target.done && s.target.hp > 0) { s.tx = s.target.x; s.ty = s.target.y; }
    const dx = s.tx - s.x, dy = s.ty - s.y, dist = Math.hypot(dx, dy), step = 9 * dt;
    if (dist <= step) { hit(s); s.hit = true; } else { s.x += (dx / dist) * step; s.y += (dy / dist) * step; }
  }
  shots = shots.filter((s) => !s.hit);
  for (const e of enemies) if (e.hp <= 0 && !e.done) { gold += ENEMIES[e.kind].reward; e.done = true; }
  enemies = enemies.filter((e) => !e.done);
  for (const b of booms) b.t -= dt;
  booms = booms.filter((b) => b.t > 0);

  if (lives <= 0) { lives = 0; state = "lost"; endedAt = performance.now(); }
  else if (waveActive && !queue.length && !enemies.length) {
    waveActive = false;
    const bonus = 20 + wave * 5;
    gold += bonus;
    if (wave === WAVES) { state = "won"; endedAt = performance.now(); } else say(`Wave ${wave} cleared: +${bonus} gold`);
  }
}

function hit(s) {
  const type = TOWERS[s.tower.type], dmg = damage(s.tower);
  if (type.splash) {
    for (const e of enemies) if (!e.done && Math.hypot(e.x - s.tx, e.y - s.ty) <= type.splash) e.hp -= dmg;
    booms.push({ x: s.tx, y: s.ty, r: type.splash, t: 0.25 });
  } else if (!s.target.done && s.target.hp > 0) {
    s.target.hp -= dmg;
    if (type.slow) s.target.slowT = type.slow;
  }
}

// ----- Drawing -----
const px = (gx) => ox + (gx + 0.5) * cell, py = (gy) => oy + (gy + 0.5) * cell;

function text(str, x, y, size, align = "left", color = "#eef5e9", weight = 600) {
  ctx.font = `${weight} ${size}px system-ui, sans-serif`;
  ctx.fillStyle = color; ctx.textAlign = align; ctx.textBaseline = "middle";
  ctx.fillText(str, x, y);
}

function circle(x, y, r, fill, stroke) {
  ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
  if (fill) { ctx.fillStyle = fill; ctx.fill(); }
  if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }
}

function draw() {
  ctx.fillStyle = "#13201a"; ctx.fillRect(0, 0, W, H);
  // Map: grass checkerboard, sandy path.
  for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
    ctx.fillStyle = pathCells.has(`${x},${y}`) ? "#c9a96e" : (x + y) % 2 ? "#3e7a3f" : "#43833f";
    ctx.fillRect(ox + x * cell, oy + y * cell, cell, cell);
  }
  // Range preview under the cursor: the selected tower on empty grass, or the tower that's there.
  const inMap = cursor.x >= 0 && cursor.y >= 0 && cursor.x < COLS && cursor.y < ROWS;
  if (inMap && state === "playing") {
    const t = towers.find((t) => t.gx === cursor.x && t.gy === cursor.y);
    const free = !t && !pathCells.has(`${cursor.x},${cursor.y}`);
    if (t || free) circle(px(cursor.x), py(cursor.y), (t ? range(t) : TOWERS[selected].range) * cell, "rgba(255, 255, 255, 0.1)", "rgba(255, 255, 255, 0.4)");
    ctx.strokeStyle = free || t ? "#ffffff" : "#ff8a80"; ctx.lineWidth = 2;
    ctx.strokeRect(ox + cursor.x * cell + 1, oy + cursor.y * cell + 1, cell - 2, cell - 2);
  }
  // Towers: a base, a barrel pointing at the target and one pip per level.
  for (const t of towers) {
    const x = px(t.gx), y = py(t.gy), type = TOWERS[t.type];
    circle(x, y, cell * 0.38, "#24302a");
    circle(x, y, cell * 0.28, type.color);
    ctx.strokeStyle = "#1b1b1b"; ctx.lineWidth = cell * 0.14;
    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(t.angle) * cell * 0.4, y + Math.sin(t.angle) * cell * 0.4); ctx.stroke();
    for (let l = 0; l < t.level; l++) circle(x - cell * 0.2 + l * cell * 0.2, y + cell * 0.38, cell * 0.06, "#fff");
  }
  // Enemies, shots and blasts are clipped to the map so they can enter and leave off the edges.
  ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, cell * COLS, cell * ROWS); ctx.clip();
  for (const e of enemies) {
    const k = ENEMIES[e.kind], x = px(e.x), y = py(e.y), r = k.size * cell;
    circle(x, y, r, e.slowT > 0 ? "#90caf9" : k.color, "#1b1b1b");
    ctx.fillStyle = "#1b1b1b"; ctx.fillRect(x - r, y - r - 7, r * 2, 4);
    ctx.fillStyle = "#76ff03"; ctx.fillRect(x - r, y - r - 7, r * 2 * Math.max(0, e.hp / e.maxHp), 4);
  }
  for (const s of shots) circle(px(s.x), py(s.y), Math.max(2, cell * 0.07), TOWERS[s.tower.type].color);
  for (const b of booms) circle(px(b.x), py(b.y), b.r * cell * (1.2 - b.t * 2), null, `rgba(255, 183, 77, ${b.t * 4})`);
  ctx.restore();

  // Top bar.
  const s = Math.min(1, W / 420);
  text(`Wave ${wave}/${WAVES}`, 12, 22, 17 * s);
  text(`♥ ${lives}`, W / 2, 22, 17 * s, "center", "#ff8a80");
  text(`${gold} gold`, W - 12, 22, 17 * s, "right", "#ffd54f");

  // Shop buttons.
  for (const b of buttons) {
    const isTower = typeof b.id === "number", type = isTower ? TOWERS[b.id] : null;
    const on = isTower ? b.id === selected : b.id === "wave" ? !waveActive && wave < WAVES : speed > 1;
    ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, 8);
    ctx.fillStyle = on ? "#2f5d3f" : "#1d2c24"; ctx.fill();
    ctx.lineWidth = 2; ctx.strokeStyle = on ? "#a5d6a7" : "#33463b"; ctx.stroke();
    const fs = Math.max(12, Math.min(17, b.h * 0.24)), cx = b.x + b.w / 2, cy = b.y + b.h / 2;
    if (isTower) {
      const poor = gold < type.cost;
      circle(b.x + Math.min(22, b.w * 0.16), cy, Math.min(10, b.h * 0.14), type.color);
      const tx = b.x + Math.min(40, b.w * 0.3);
      const rows = b.w < 150 ? [type.name, `${type.cost} gold`] : [`${b.id + 1}  ${type.name}`, `${type.cost} gold · ${type.info}`];
      text(rows[0], tx, cy - fs * 0.6, fs, "left");
      text(rows[1], tx, cy + fs * 0.65, fs * 0.8, "left", poor ? "#ff8a80" : "#c5d8c9", 500);
    } else if (b.id === "wave") {
      text(waveActive ? `Wave ${wave} in progress` : wave < WAVES ? `Start wave ${wave + 1}` : "Last wave", cx, cy, fs, "center", on ? "#fff" : "#8aa395");
    } else text(`Speed ×${speed}`, cx, cy, fs, "center");
  }

  if (toast) {
    ctx.font = "700 14px system-ui, sans-serif";
    const tw = ctx.measureText(toast.text).width + 20, tx = ox + (cell * COLS) / 2, ty = oy + cell * ROWS - 20;
    ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(tx - tw / 2, ty - 13, tw, 26);
    text(toast.text, tx, ty, 14, "center", "#fff", 700);
  }

  if (state !== "playing") {
    ctx.fillStyle = "rgba(8, 16, 11, 0.85)"; ctx.fillRect(0, 0, W, H);
    const cx = W / 2, cy = H / 2;
    if (state === "ready") {
      text("Grove Guard", cx, cy - 96 * s, 38 * s, "center", "#a5d6a7", 800);
      text(`Stop ${WAVES} waves of creeps from crossing the grove.`, cx, cy - 44 * s, 16 * s, "center");
      text("Pick a tower, then tap the grass beside the path.", cx, cy - 18 * s, 16 * s, "center");
      text("Tap a tower again to upgrade it.", cx, cy + 8 * s, 16 * s, "center");
      text("Keys: 1–3 tower, arrows move, Enter build,", cx, cy + 46 * s, 14 * s, "center", "#c5d8c9", 500);
      text("Space next wave, F speed", cx, cy + 68 * s, 14 * s, "center", "#c5d8c9", 500);
      text("Tap or press Enter to start", cx, cy + 108 * s, 18 * s, "center", "#ffd54f", 700);
    } else {
      text(state === "won" ? "The grove is safe!" : "The grove has fallen", cx, cy - 50 * s, 32 * s, "center", "#a5d6a7", 800);
      text(state === "won" ? `All ${WAVES} waves held with ${lives} lives left` : `You held out until wave ${wave}`, cx, cy, 17 * s, "center");
      text("Tap or press Enter to play again", cx, cy + 50 * s, 18 * s, "center", "#ffd54f", 700);
    }
  }
}

// ----- Main loop -----
function loop(now) {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;
  // Fast-forward runs the same update twice, so the rules behave exactly as at normal speed.
  for (let i = 0; i < speed; i++) update(dt);
  if (toast && (toast.t -= dt) <= 0) toast = null;
  draw();
  requestAnimationFrame(loop);
}

// ----- Input: tap or click the shop and the map; keyboard moves a cursor -----
function press(b) {
  if (typeof b.id === "number") selected = b.id;
  else if (b.id === "wave") nextWave();
  else speed = speed === 1 ? 2 : 1;
}

function cellAt(x, y) { return { x: Math.floor((x - ox) / cell), y: Math.floor((y - oy) / cell) }; }

canvas.addEventListener("pointerdown", (e) => {
  usingKeys = false;
  if (state !== "playing") { begin(); return; }
  const b = buttons.find((b) => e.clientX >= b.x && e.clientX <= b.x + b.w && e.clientY >= b.y && e.clientY <= b.y + b.h);
  if (b) { press(b); return; }
  const c = cellAt(e.clientX, e.clientY);
  if (c.x >= 0 && c.y >= 0 && c.x < COLS && c.y < ROWS) { cursor = c; buildOrUpgrade(c.x, c.y); }
});
canvas.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse" && !usingKeys) cursor = cellAt(e.clientX, e.clientY); });

const MOVES = { ArrowLeft: [-1, 0], KeyA: [-1, 0], ArrowRight: [1, 0], KeyD: [1, 0], ArrowUp: [0, -1], KeyW: [0, -1], ArrowDown: [0, 1], KeyS: [0, 1] };
window.addEventListener("keydown", (e) => {
  const k = e.code;
  if (MOVES[k] || k === "Space" || k === "Enter") e.preventDefault();
  if (state !== "playing") { if (k === "Enter" || k === "Space") begin(); return; }
  usingKeys = true;
  if (MOVES[k]) {
    cursor = { x: Math.max(0, Math.min(COLS - 1, cursor.x + MOVES[k][0])), y: Math.max(0, Math.min(ROWS - 1, cursor.y + MOVES[k][1])) };
  } else if (k === "Digit1" || k === "Digit2" || k === "Digit3") selected = Number(k.slice(5)) - 1;
  else if (k === "Enter" || k === "KeyB") buildOrUpgrade(cursor.x, cursor.y);
  else if (k === "Space" || k === "KeyN") nextWave();
  else if (k === "KeyF") speed = speed === 1 ? 2 : 1;
});

window.addEventListener("resize", resize);
resize();
begin(); state = "ready";            // set up an empty map behind the start screen
requestAnimationFrame((t) => { last = t; loop(t); });
</script>
</body>
</html>

Frequently asked questions

Can I use this tower defense code in a commercial game?

Yes. The file is MIT-licensed, so you can change it, add your own art and sell the result. Keep the licence line at the top of the file.

Should I use a game engine for a tower defense game?

For learning, no: this one needs only the canvas API. An engine like Phaser starts to pay off when you add sprite animation, particle effects, sound, menus and many levels, which is most of the work in a commercial tower defense game.

How do I add more levels?

Turn PATH, START_GOLD and the wave settings into a list of level objects, and load one when the player picks a level. Hand-written wave lists (for example "10 runners, then 3 brutes") give you more control than the formula in waveList().

How do I put this game on my website?

Upload the HTML file to your host and link to it, or show it in an <iframe>. Our guide to embedding an HTML5 game in a website covers WordPress, Wix and Google Sites.

How do I save progress or a high score?

Save the highest wave reached with localStorage.setItem when a game ends and read it back on start. Wrap both calls in 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.