A tower-defense game looks like it wants an engine: pathfinding, an entity system, some AI to run the creeps. It needs none of that. On screen there is no "level" at all — only a hand-written list of waypoints, an array of enemies each remembering how far it has crawled, an array of towers each with a range and a cooldown, and an array of projectiles. About 200 lines of vanilla JavaScript on one canvas. Here's how I built it.
The map is a list of corners
There's no tile editor. The path is just an array of grid corners — waypoints — that the road turns at. Walk from each corner to the next, one cell at a time, and mark those cells as "road" in a Set. A tower may be built on any cell that is on the board, is not road, and isn't already occupied. That single set is the entire map.
const WPC = [[0,2],[4,2],[4,10],[9,10],[9,4],[14,4],[14,10],[17,10],[17,2],[19,2]];
const road = new Set();
for (let i = 0; i < WPC.length-1; i++){
let [c,r] = WPC[i]; const [tc,tr] = WPC[i+1];
const dc = Math.sign(tc-c), dr = Math.sign(tr-r);
road.add(c+','+r);
while (c !== tc || r !== tr){ c += dc; r += dr; road.add(c+','+r); }
}
An enemy is a point that lerps toward the next corner
Each enemy knows which segment it's on. Every frame I give it a budget of movement — speed × slow × dt pixels — and spend it walking straight toward the next waypoint. If it reaches that corner with budget left, it advances a segment and keeps going, so it banks corners smoothly. Every pixel it moves gets added to dist — its total progress — which the towers read to pick a target. Run off the last waypoint and it has leaked.
function moveEnemy(e, dt){
let budget = e.speed * e.slow * dt;
while (budget > 0 && e.seg < WP.length-1){
const t = WP[e.seg+1];
const dx = t.x-e.x, dy = t.y-e.y, d = Math.hypot(dx,dy);
if (d <= budget){ e.x = t.x; e.y = t.y; budget -= d; e.dist += d; e.seg++; }
else { e.x += dx/d*budget; e.y += dy/d*budget; e.dist += budget; budget = 0; }
}
if (e.seg >= WP.length-1) e.leaked = true;
}
A tower auto-targets the creep furthest along
A tower does two things a frame: pick a target, then fire if ready. Picking is a loop — of all enemies inside range, keep the one with the greatest dist, the creep furthest along the path and closest to your base. That's the classic "first" targeting most TD players expect (swap the comparison for nearest or lowest-HP). A cooldown counts down; when it hits zero and there's a target, fire and reset it.
function updateTower(tw, dt){
tw.cd -= dt;
let target = null, best = -1;
for (const e of enemies){
if (e.dead || e.leaked) continue;
if (Math.hypot(e.x-tw.x, e.y-tw.y) <= tw.range && e.dist > best){ best = e.dist; target = e; }
}
if (target){
tw.angle = Math.atan2(target.y-tw.y, target.x-tw.x); // swivel the barrel
if (tw.cd <= 0){ fire(tw, target); tw.cd = tw.fireRate; }
}
}
Projectiles home onto their target object
Firing pushes a projectile that remembers its target object, not a fixed point — so it curves to follow a moving creep. Each frame it steps toward the target; when the gap closes to within the enemy's radius it's a hit. A cannon shell also damages everyone inside its splash radius; a frost bolt sets the target's slow factor and a timer. If the target dies mid-flight, the shot just fizzles.
The economy is the whole game of it
Damage is a subtraction; death is the payout. When an enemy's hit-points cross zero it's flagged dead and its reward lands in your gold. Building spends gold — only if you can afford the tower and the tile is buildable does the purchase go through. That loop — kills fund towers, towers make kills — is the entire tug-of-war. Waves escalate on an exponential Math.pow(1.14, n-1), mixing in fast runners and armoured tanks with a boss every fifth wave, so later waves genuinely bite.
One step(dt) runs it all in order — spawn, move (a leak costs a life; zero lives ends the run), aim and fire, advance projectiles, sweep the dead — inside one requestAnimationFrame loop, dt-scaled so ×2 speed is just calling step twice. Nowhere is there an "engine". Place towers, upgrade, sell, send the next wave, and try to survive all twenty:
Top comments (0)