Build Your Own Asteroids++
A step-by-step guide to recreating the Asteroids++ game from scratch: random polygon asteroids, pixel-perfect collision, wrap-around physics, and infinite rounds. All client-side with vanilla JavaScript and the Canvas 2D API.
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 — more on that later.
Step 2: Game Loop
Asteroids uses requestAnimationFrame for smooth 60fps animation. The loop tracks delta time (dt) so physics run at the same speed regardless of frame rate:
var lastTime = 0;
function gameLoop(timestamp) {
if (!lastTime) lastTime = timestamp;
var dt = Math.min((timestamp - lastTime) / 1000, 0.05); // cap at 50ms
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 (['Space','ArrowUp','ArrowLeft','ArrowRight'].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: Polygon Generation
Asteroids are random polygons. The algorithm places vertices at sorted random angles around a centre point, using a smooth random walk on radius to avoid self-intersection:
function generatePolygon(vertices, size) {
var angles = [];
for (var i = 0; i < vertices; i++) angles.push(Math.random() * 2 * Math.PI);
angles.sort();
var r = size / 2;
var points = [];
var radius = 0.5 + Math.random() * 0.5; // start between 0.5 and 1.0
for (var i = 0; i < angles.length; i++) {
radius = Math.max(0.3, Math.min(1.0, radius + (Math.random() - 0.5) * 0.3));
points.push({
x: radius * r * Math.cos(angles[i]),
y: radius * r * Math.sin(angles[i])
});
}
return points;
}
Why this works: Sorting the angles ensures edges don't cross (they radiate outward in order). The radius walk creates organic irregularity — clamped between 0.3 and 1.0 so no vertex collapses to the centre or shoots out too far.
Area check
Small or degenerate polygons make lousy asteroids. Reject any polygon whose area is below a threshold:
function polygonArea(pts) {
var area = 0;
var n = pts.length;
for (var i = 0; i < n; i++) {
var j = (i + 1) % n;
area += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
}
return Math.abs(area) / 2;
}
function generatePolygon(vertices, size) {
var minArea = size * size * 0.03;
var best = null, bestArea = 0;
for (var attempt = 0; attempt < 15; attempt++) {
// ... generate as above ...
var a = polygonArea(points);
if (a >= minArea) return points;
if (a > bestArea) { best = points; bestArea = a; }
}
return best; // fallback to best attempt
}
Step 5: The Ship
The ship is a triangle. Define it as a polygon relative to its centre:
var SHIP_POLYGON = [
{ x: 22.5, y: 0 }, // nose
{ x: -15, y: -12 }, // left fin
{ x: -15, y: 12 }, // right fin
];
Ship state includes position, velocity, angle, and invincibility timer:
var ship = {
x: W / 2, y: H / 2,
vx: 0, vy: 0,
angle: -Math.PI / 2, // pointing up
invincible: 0,
};
Controls update velocity and angle in the update() function:
if (keys['ArrowLeft']) ship.angle -= 4 * dt;
if (keys['ArrowRight']) ship.angle += 4 * dt;
if (keys['ArrowUp']) {
ship.vx += Math.cos(ship.angle) * 300 * dt;
ship.vy += Math.sin(ship.angle) * 300 * dt;
}
Step 6: Drawing Polygons
To draw any polygon (ship, asteroid) with the game's outline style:
function drawPolygon(pts, cx, cy, angle, color) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
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.08; // faint fill
ctx.fill();
ctx.globalAlpha = 1;
ctx.stroke();
ctx.restore();
}
The colour should respond to the page theme — white outlines in dark mode, black in light mode:
function outlineColor() {
var theme = document.documentElement.getAttribute('data-bs-theme');
return theme === 'dark' ? 'white' : 'black';
}
Step 7: Screen Wrap-Around
Objects move freely in an unbounded coordinate space. Rendering and collision use modulo projection to map any real coordinate to the visible screen, so there is never a teleport:
var sx = ((cx % W) + W) % W; // screen-space x in [0, W)
var sy = ((cy % H) + H) % H;
Rendering
For each object, project its position to screen-space, then draw up to 9 copies (the projected position plus all 8 neighbouring screen offsets). Objects straddling the screen edge appear on both sides simultaneously:
function drawWrappedPolygon(pts, cx, cy, angle, color, radius) {
var sx = ((cx % W) + W) % W;
var sy = ((cy % H) + H) % H;
for (var dx = -W; dx <= W; dx += W) {
for (var dy = -H; dy <= H; dy += H) {
var wx = sx + dx, wy = sy + dy;
if (wx + radius > 0 && wx - radius < W &&
wy + radius > 0 && wy - radius < H) {
drawPolygon(pts, wx, wy, angle, color);
}
}
}
}
As cx increases past a multiple of W, sx wraps smoothly from 1023 → 0 → 1 — the rendered copy glides across the edge pixel by pixel, never jumping.
Collision
Apply the same modulo projection before checking wrapped collisions:
function wrappedCollision(pa, cxa, cya, anga, pb, cxb, cyb, angb) {
var sa = ((cxa % W) + W) % W;
var sb = ((cya % H) + H) % H;
for (var dx = -W; dx <= W; dx += W)
for (var dy = -H; dy <= H; dy += H)
if (polygonCollision(pa, sa + dx, sb + dy, anga, pb, cxb, cyb, angb))
return true;
return false;
}
Position
Positions are never wrapped during normal play. A wrapFar() function only kicks in at ±100 screen widths to prevent floating-point precision loss:
function wrapFar(val, max) {
if (val > -max * 100 && val < max * 100) return val;
return ((val % max) + max) % max;
}
// In update:
ship.x = wrapFar(ship.x, W);
ship.y = wrapFar(ship.y, H);
Step 8: Bullets
Bullets are small, fast-moving points with a limited lifetime:
function fireBullet() {
var noseX = ship.x + Math.cos(ship.angle) * 22.5;
var noseY = ship.y + Math.sin(ship.angle) * 22.5;
bullets.push({
x: noseX, y: noseY,
vx: Math.cos(ship.angle) * 400,
vy: Math.sin(ship.angle) * 400,
life: 1.0, // seconds
});
}
Update position and decrement life each frame. Remove dead bullets. Apply the same wrap-around logic.
Step 9: Collision Detection
Point in polygon (bullet vs asteroid)
Cast a ray from the point and count how many polygon edges it crosses. Odd = inside:
function pointInPolygon(px, py, poly) {
var inside = false;
var 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;
}
Before checking, transform the asteroid's polygon points from local to world coordinates using the asteroid's position and rotation:
function getWorldPoints(poly, cx, cy, angle) {
var c = Math.cos(angle), s = Math.sin(angle);
return poly.map(function(p) {
return { x: p.x * c - p.y * s + cx, y: p.x * s + p.y * c + cy };
});
}
Polygon vs polygon (ship vs asteroid)
Two phases:
- Edge intersection — test every edge of A against every edge of B using the cross-product line intersection test.
- Vertex containment — check if any vertex of A is inside B (or vice versa) using
pointInPolygon.
As a broad-phase optimisation, do a quick circle-distance check first:
function polygonCollision(polyA, cxA, cyA, angA, polyB, cxB, cyB, angB) {
// Broad phase: circle check
if (Math.hypot(cxA - cxB, cyA - cyB) > maxRadiusA + maxRadiusB) return false;
var wa = getWorldPoints(polyA, cxA, cyA, angA);
var wb = getWorldPoints(polyB, cxB, cyB, angB);
// Edge intersection
for (var i = 0; i < wa.length; i++) {
var j = (i + 1) % wa.length;
for (var k = 0; k < wb.length; k++) {
var l = (k + 1) % wb.length;
if (segmentsIntersect(wa[i], wa[j], wb[k], wb[l])) return true;
}
}
// Vertex containment
for (var i = 0; i < wa.length; i++)
if (pointInPolygon(wa[i].x, wa[i].y, wb)) return true;
for (var i = 0; i < wb.length; i++)
if (pointInPolygon(wb[i].x, wb[i].y, wa)) return true;
return false;
}
The segment intersection test uses the 2D cross product:
function cross2(ax, ay, bx, by) { return ax * by - ay * bx; }
function segmentsIntersect(a, b, c, d) {
var denom = cross2(b.x - a.x, b.y - a.y, d.x - c.x, d.y - c.y);
if (Math.abs(denom) < 1e-10) return false; // parallel
var t = cross2(c.x - a.x, c.y - a.y, d.x - c.x, d.y - c.y) / denom;
var u = cross2(c.x - a.x, c.y - a.y, b.x - a.x, b.y - a.y) / denom;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
Wrap-aware collision
Since an asteroid may be visually split across the screen edge, check all 9 wrapped positions of the target:
function wrappedCollision(polyA, cxA, cyA, angA, polyB, cxB, cyB, angB) {
for (var dx = -W; dx <= W; dx += W)
for (var dy = -H; dy <= H; dy += H)
if (polygonCollision(polyA, cxA + dx, cyA + dy, angA, polyB, cxB, cyB, angB))
return true;
return false;
}
Step 10: Asteroid Splitting
Three size tiers:
var ASTEROID_SIZES = [
{ radius: 90, vertices: [8, 14], score: 20 },
{ radius: 50, vertices: [6, 10], score: 50 },
{ radius: 25, vertices: [5, 8], score: 100 },
];
When hit, an asteroid splits into two of the next size down:
function splitAsteroid(ast) {
if (ast.sizeIndex >= ASTEROID_SIZES.length - 1) return [];
var next = ast.sizeIndex + 1;
return [
createAsteroid(ast.x + rand(-15, 15), ast.y + rand(-15, 15), next),
createAsteroid(ast.x + rand(-15, 15), ast.y + rand(-15, 15), next),
];
}
Step 11: Infinite Rounds
When all asteroids are cleared, advance to the next round:
function startRound() {
round++;
multiplier = 1 + (round - 1) * 0.5;
var count = 3 + round * 2;
asteroids = [];
for (var i = 0; i < count; i++) {
asteroids.push(createAsteroid(
rand(50, W - 50), rand(50, H - 50), 0
));
}
// Reset ship position with invincibility
ship.x = W / 2; ship.y = H / 2;
ship.vx = 0; ship.vy = 0;
ship.angle = -Math.PI / 2;
ship.invincible = 1.5;
}
Spawn asteroids away from the ship's centre position by checking distance.
Step 12: Game Over
Set running = false. The update loop skips ship controls and bullets but continues to drift asteroids. Redraw the scene with a semi-transparent overlay and "Game Over" text:
function draw() {
ctx.clearRect(0, 0, W, H);
// ... draw stars, asteroids, ship, bullets ...
if (!running) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.font = 'bold 48px sans-serif';
ctx.fillText('Game Over', W / 2, H / 2 - 30);
ctx.font = '16px sans-serif';
ctx.fillText('Press Space to play again', W / 2, H / 2 + 40);
}
}
Step 13: HiDPI Support
On Retina displays, the canvas looks soft unless you scale the buffer:
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
The CSS keeps the display size at the logical resolution (e.g. width: 100%; max-width: 800px;). The internal buffer is dpr times larger for crisp rendering.
Putting It All Together
The update(dt) function runs every frame in this order:
- Drift asteroids (even when dead — they float forever)
- Check round completion — if no asteroids remain, advance round
- Decay timers — invincibility, fire cooldown
- Ship controls — rotation, thrust (only if alive)
- Ship movement — apply velocity, wrap
- Bullets — move, decay, remove expired
- Collisions — bullet→asteroid (hit → split + score), ship→asteroid (hit → death)
- Win condition — if all asteroids gone, start new round
The draw() function renders in this order:
- Stars — random dots as background
- Asteroids — wrapped polygon rendering
- Ship — wrapped polygon (only if alive)
- Bullets — wrapped small squares
- Game Over overlay — if dead
Next Steps
- Add particle effects on asteroid destruction
- Implement power-ups (spread shot, shield, extra life)
- Add sound effects using the Web Audio API
- Track session stats (time played, accuracy, biggest round)
- Add mobile touch controls
Reference
- MDN Canvas API
- Ray casting algorithm (point in polygon)
- Separating Axis Theorem
- See the full implementation at
/asteroidsandtemplates/asteroids.html