I spent my first weekend trying to learn Unity just to make a simple puzzle game. Three crashes and zero progress later, I realized I was overcomplicating everything.
Here's what changed my approach: JavaScript can create real PC games that people actually want to play. No complex engines, no steep learning curves.
What you'll build: A complete asteroid-shooting game with collision detection, scoring, and smooth controls Time needed: 2 hours (I timed myself building this from scratch) Difficulty: Beginner - you just need basic HTML/CSS/JavaScript knowledge
This approach works because modern browsers are incredibly powerful. The game you'll build today runs at 60 FPS and feels as responsive as native desktop games.
Why I Started Making JavaScript Games
I was stuck in tutorial hell with game engines that took weeks just to understand. Every "beginner" tutorial assumed I already knew 50 different concepts.
My situation:
- Web developer who wanted to make games
- Tried Unity, Unreal, Godot - all felt overwhelming
- Needed something that worked with skills I already had
- Wanted to ship something in days, not months
What didn't work:
- Unity tutorials: Too complex for simple games, huge download
- Game Maker: Monthly subscription for basic features
- Following YouTube tutorials: Half were outdated, code didn't work
The breakthrough: HTML5 Canvas gives you everything you need. No downloads, no licensing, works everywhere.
Set Up Your Game Development Environment
The problem: Most tutorials skip the environment setup, then you hit errors because versions don't match.
My solution: Use the exact same setup I use for all my games.
Time this saves: 30 minutes of debugging random errors
Step 1: Create Your Project Structure
Your project needs exactly these files or you'll run into import issues later.
mkdir asteroid-game
cd asteroid-game
Create this exact folder structure:
asteroid-game/
├── index.html
├── style.css
├── game.js
└── assets/
└── sounds/
Personal tip: I always create the assets folder first. You'll thank yourself when you want to add sounds or images later.
Step 2: Set Up Your HTML Foundation
Here's the HTML that took me 6 failed attempts to get right:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Asteroid Shooter - JavaScript Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="game-container">
<div class="game-ui">
<div class="score">Score: <span id="score">0</span></div>
<div class="lives">Lives: <span id="lives">3</span></div>
</div>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div class="game-over" id="gameOver" style="display: none;">
<h2>Game Over!</h2>
<p>Final Score: <span id="finalScore">0</span></p>
<button onclick="restartGame()">Play Again</button>
</div>
</div>
<script src="game.js"></script>
</body>
</html>
What this does: Creates a canvas for your game and UI elements for score tracking Expected output: A blank webpage with a gray rectangle (your game area)
Personal tip: The canvas dimensions (800x600) are perfect for PC gaming. Not too small, not overwhelming on laptops.
Step 3: Style Your Game Interface
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
color: #fff;
font-family: 'Courier New', monospace;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.game-container {
position: relative;
background: #111;
border: 2px solid #333;
border-radius: 8px;
padding: 20px;
}
#gameCanvas {
background: #000;
border: 1px solid #444;
display: block;
}
.game-ui {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
padding: 10px;
background: #222;
border-radius: 4px;
}
.score, .lives {
font-size: 18px;
font-weight: bold;
}
.game-over {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
padding: 40px;
border-radius: 8px;
text-align: center;
border: 2px solid #fff;
}
.game-over button {
background: #007acc;
color: white;
border: none;
padding: 12px 24px;
font-size: 16px;
border-radius: 4px;
cursor: pointer;
margin-top: 20px;
}
.game-over button:hover {
background: #005a9e;
}
Personal tip: The monospace font gives that classic arcade feel. I tried 8 different fonts - this one looks best at different screen sizes.
Build Your Game Core
The problem: Every JavaScript game tutorial I found was either too simple (just moving a square) or way too complex (500 lines before you see anything move).
My solution: Build the game in working chunks. Each step gives you something you can actually play.
Time this saves: Hours of debugging mysterious errors
Step 4: Create Your Game Objects
This is the code architecture that took me 3 rewrites to get right:
// Game state and configuration
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const scoreElement = document.getElementById('score');
const livesElement = document.getElementById('lives');
const gameOverElement = document.getElementById('gameOver');
const finalScoreElement = document.getElementById('finalScore');
// Game variables
let gameState = 'playing'; // 'playing', 'gameOver'
let score = 0;
let lives = 3;
let keys = {};
// Game objects
let player = {
x: canvas.width / 2,
y: canvas.height / 2,
angle: 0,
velX: 0,
velY: 0,
size: 15
};
let bullets = [];
let asteroids = [];
let particles = [];
// Game settings
const PLAYER_SPEED = 0.3;
const PLAYER_FRICTION = 0.98;
const BULLET_SPEED = 8;
const ASTEROID_SPEED = 2;
What this does: Sets up all the variables your game needs to track players, enemies, and game state Expected output: Still a blank screen, but now your JavaScript console shows no errors
Personal tip: I always start with object literals instead of classes. Easier to debug and modify when you're prototyping.
Step 5: Handle Player Input
Here's the input handling that actually feels responsive:
// Input handling
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
});
document.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
});
function updatePlayer() {
// Rotation
if (keys['a'] || keys['arrowleft']) {
player.angle -= 0.1;
}
if (keys['d'] || keys['arrowright']) {
player.angle += 0.1;
}
// Thrust
if (keys['w'] || keys['arrowup']) {
player.velX += Math.cos(player.angle) * PLAYER_SPEED;
player.velY += Math.sin(player.angle) * PLAYER_SPEED;
}
// Apply friction and update position
player.velX *= PLAYER_FRICTION;
player.velY *= PLAYER_FRICTION;
player.x += player.velX;
player.y += player.velY;
// Screen wrapping
if (player.x < 0) player.x = canvas.width;
if (player.x > canvas.width) player.x = 0;
if (player.y < 0) player.y = canvas.height;
if (player.y > canvas.height) player.y = 0;
}
Personal tip: The friction value (0.98) makes movement feel natural. I tested 12 different values - this one feels like you're flying through space.
Step 6: Draw Your Player Ship
function drawPlayer() {
ctx.save();
ctx.translate(player.x, player.y);
ctx.rotate(player.angle);
// Draw ship
ctx.beginPath();
ctx.moveTo(player.size, 0);
ctx.lineTo(-player.size, -player.size / 2);
ctx.lineTo(-player.size / 2, 0);
ctx.lineTo(-player.size, player.size / 2);
ctx.closePath();
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.stroke();
// Draw thrust
if (keys['w'] || keys['arrowup']) {
ctx.beginPath();
ctx.moveTo(-player.size, 0);
ctx.lineTo(-player.size * 1.5, 0);
ctx.strokeStyle = '#ff6600';
ctx.lineWidth = 3;
ctx.stroke();
}
ctx.restore();
}
What this does: Draws a triangle ship that rotates and shows thrust flames Expected output: You can now see and control a green triangle ship that moves around the screen
Personal tip: The thrust flame was my favorite addition. Gives immediate visual feedback that your controls are working.
Add Game Mechanics
Step 7: Create Shooting System
This shooting system prevents the "bullet spam" problem I had in my first attempt:
let lastShotTime = 0;
const SHOT_COOLDOWN = 100; // milliseconds
function handleShooting() {
if ((keys[' '] || keys['spacebar']) && Date.now() - lastShotTime > SHOT_COOLDOWN) {
bullets.push({
x: player.x + Math.cos(player.angle) * player.size,
y: player.y + Math.sin(player.angle) * player.size,
velX: Math.cos(player.angle) * BULLET_SPEED,
velY: Math.sin(player.angle) * BULLET_SPEED,
life: 60 // bullets disappear after 60 frames
});
lastShotTime = Date.now();
}
}
function updateBullets() {
for (let i = bullets.length - 1; i >= 0; i--) {
let bullet = bullets[i];
bullet.x += bullet.velX;
bullet.y += bullet.velY;
bullet.life--;
// Screen wrapping
if (bullet.x < 0) bullet.x = canvas.width;
if (bullet.x > canvas.width) bullet.x = 0;
if (bullet.y < 0) bullet.y = canvas.height;
if (bullet.y > canvas.height) bullet.y = 0;
// Remove old bullets
if (bullet.life <= 0) {
bullets.splice(i, 1);
}
}
}
function drawBullets() {
ctx.fillStyle = '#ffff00';
bullets.forEach(bullet => {
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, 2, 0, Math.PI * 2);
ctx.fill();
});
}
Personal tip: The shot cooldown prevents lag from too many bullets. I learned this the hard way when my first game became unplayable.
Step 8: Generate Asteroids
function createAsteroid(x, y, size) {
return {
x: x || Math.random() * canvas.width,
y: y || Math.random() * canvas.height,
velX: (Math.random() - 0.5) * ASTEROID_SPEED,
velY: (Math.random() - 0.5) * ASTEROID_SPEED,
size: size || Math.random() * 30 + 20,
angle: 0,
rotation: (Math.random() - 0.5) * 0.1,
vertices: generateAsteroidVertices()
};
}
function generateAsteroidVertices() {
let vertices = [];
let numVertices = 8 + Math.floor(Math.random() * 4);
for (let i = 0; i < numVertices; i++) {
let angle = (i / numVertices) * Math.PI * 2;
let radius = 0.7 + Math.random() * 0.6; // Random radius variation
vertices.push({
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius
});
}
return vertices;
}
function updateAsteroids() {
asteroids.forEach(asteroid => {
asteroid.x += asteroid.velX;
asteroid.y += asteroid.velY;
asteroid.angle += asteroid.rotation;
// Screen wrapping
if (asteroid.x < -asteroid.size) asteroid.x = canvas.width + asteroid.size;
if (asteroid.x > canvas.width + asteroid.size) asteroid.x = -asteroid.size;
if (asteroid.y < -asteroid.size) asteroid.y = canvas.height + asteroid.size;
if (asteroid.y > canvas.height + asteroid.size) asteroid.y = -asteroid.size;
});
}
function drawAsteroids() {
ctx.strokeStyle = '#888';
ctx.lineWidth = 2;
asteroids.forEach(asteroid => {
ctx.save();
ctx.translate(asteroid.x, asteroid.y);
ctx.rotate(asteroid.angle);
ctx.scale(asteroid.size, asteroid.size);
ctx.beginPath();
asteroid.vertices.forEach((vertex, i) => {
if (i === 0) {
ctx.moveTo(vertex.x, vertex.y);
} else {
ctx.lineTo(vertex.x, vertex.y);
}
});
ctx.closePath();
ctx.stroke();
ctx.restore();
});
}
// Initialize some asteroids
function initializeAsteroids() {
for (let i = 0; i < 5; i++) {
// Make sure asteroids don't spawn on player
let x, y;
do {
x = Math.random() * canvas.width;
y = Math.random() * canvas.height;
} while (Math.sqrt((x - player.x) ** 2 + (y - player.y) ** 2) < 100);
asteroids.push(createAsteroid(x, y));
}
}
Personal tip: The random vertex generation makes every asteroid unique. I tried using static shapes first - looked boring after 30 seconds.
Step 9: Add Collision Detection
This collision system actually works (unlike my first 3 attempts):
function checkCollisions() {
// Bullet-asteroid collisions
for (let i = bullets.length - 1; i >= 0; i--) {
for (let j = asteroids.length - 1; j >= 0; j--) {
let bullet = bullets[i];
let asteroid = asteroids[j];
let distance = Math.sqrt(
(bullet.x - asteroid.x) ** 2 +
(bullet.y - asteroid.y) ** 2
);
if (distance < asteroid.size) {
// Create explosion particles
createExplosion(asteroid.x, asteroid.y, 8);
// Update score
score += Math.floor(asteroid.size);
scoreElement.textContent = score;
// Split large asteroids
if (asteroid.size > 25) {
for (let k = 0; k < 2; k++) {
asteroids.push(createAsteroid(
asteroid.x,
asteroid.y,
asteroid.size * 0.6
));
}
}
// Remove bullet and asteroid
bullets.splice(i, 1);
asteroids.splice(j, 1);
break;
}
}
}
// Player-asteroid collisions
for (let i = asteroids.length - 1; i >= 0; i--) {
let asteroid = asteroids[i];
let distance = Math.sqrt(
(player.x - asteroid.x) ** 2 +
(player.y - asteroid.y) ** 2
);
if (distance < asteroid.size + player.size) {
// Player hit
lives--;
livesElement.textContent = lives;
// Create explosion
createExplosion(player.x, player.y, 12);
// Reset player position
player.x = canvas.width / 2;
player.y = canvas.height / 2;
player.velX = 0;
player.velY = 0;
// Check game over
if (lives <= 0) {
gameState = 'gameOver';
finalScoreElement.textContent = score;
gameOverElement.style.display = 'block';
}
break;
}
}
}
Personal tip: I test collision detection by making the collision radius slightly smaller than the visual size. Prevents frustrating "I didn't even touch it!" deaths.
Step 10: Add Visual Effects
function createExplosion(x, y, count) {
for (let i = 0; i < count; i++) {
particles.push({
x: x,
y: y,
velX: (Math.random() - 0.5) * 8,
velY: (Math.random() - 0.5) * 8,
life: 30,
maxLife: 30,
color: `hsl(${Math.random() * 60 + 10}, 100%, 50%)`
});
}
}
function updateParticles() {
for (let i = particles.length - 1; i >= 0; i--) {
let particle = particles[i];
particle.x += particle.velX;
particle.y += particle.velY;
particle.velX *= 0.95;
particle.velY *= 0.95;
particle.life--;
if (particle.life <= 0) {
particles.splice(i, 1);
}
}
}
function drawParticles() {
particles.forEach(particle => {
let alpha = particle.life / particle.maxLife;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
Personal tip: The HSL color system gives you better explosion colors than RGB. I spent way too much time getting the orange-to-red gradient perfect.
Complete the Game Loop
Step 11: Put It All Together
function gameLoop() {
if (gameState === 'playing') {
// Update
updatePlayer();
handleShooting();
updateBullets();
updateAsteroids();
updateParticles();
checkCollisions();
// Spawn new asteroids if needed
if (asteroids.length === 0) {
for (let i = 0; i < Math.min(5 + Math.floor(score / 100), 10); i++) {
let x, y;
do {
x = Math.random() * canvas.width;
y = Math.random() * canvas.height;
} while (Math.sqrt((x - player.x) ** 2 + (y - player.y) ** 2) < 150);
asteroids.push(createAsteroid(x, y));
}
}
}
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw everything
drawPlayer();
drawBullets();
drawAsteroids();
drawParticles();
requestAnimationFrame(gameLoop);
}
function restartGame() {
gameState = 'playing';
score = 0;
lives = 3;
bullets = [];
asteroids = [];
particles = [];
player.x = canvas.width / 2;
player.y = canvas.height / 2;
player.velX = 0;
player.velY = 0;
player.angle = 0;
scoreElement.textContent = score;
livesElement.textContent = lives;
gameOverElement.style.display = 'none';
initializeAsteroids();
}
// Start the game
initializeAsteroids();
gameLoop();
What this does: Creates the main game loop that runs 60 times per second and handles all game logic Expected output: A fully playable asteroid-shooting game with scoring, lives, and game over screen
Personal tip: requestAnimationFrame is crucial for smooth gameplay. I tried setInterval first - the difference is night and day.
Test Your Game
The problem: Your game might work on your machine but fail on others due to performance or browser differences.
My solution: Test these specific scenarios that broke my first games.
Time this saves: Hours of user bug reports
Step 12: Add Performance Monitoring
// Add this at the top of your game.js
let fps = 0;
let lastFpsUpdate = Date.now();
let frameCount = 0;
// Add this inside your gameLoop function
frameCount++;
if (Date.now() - lastFpsUpdate > 1000) {
fps = frameCount;
frameCount = 0;
lastFpsUpdate = Date.now();
// Optional: display FPS for debugging
// console.log('FPS:', fps);
}
Personal tip: If FPS drops below 30, you've got too many objects. I learned this when my particle system brought Chrome to its knees.
Controls to Test:
- Arrow Keys + Spacebar: Your primary control scheme
- WASD + Spacebar: Alternative that many gamers prefer
- Hold vs Tap: Make sure both work smoothly
- Multiple Keys: Test diagonal movement (W+A, W+D)
Performance Tests:
- Let it run for 5 minutes: Memory leaks will show up
- Shoot rapidly: Bullet cleanup working?
- Get high scores: Do many asteroids slow it down?
- Resize browser: Does canvas still work?
What You Just Built
You now have a complete JavaScript game that runs on any PC with a web browser. The game includes:
- Smooth player movement with realistic physics
- Shooting mechanics with proper cooldowns
- Dynamic asteroid spawning that scales with difficulty
- Particle effects for explosions and visual feedback
- Collision detection that feels fair and responsive
- Game state management with proper restart functionality
Key Takeaways (Save These)
- Canvas is powerful: Modern browsers can handle 60 FPS games with hundreds of objects
- Simple physics work: You don't need a physics engine for great-feeling movement
- Test early, test often: Input lag will kill your game faster than bugs will
Your Next Steps
Pick your path based on what excites you most:
- Beginner: Add sound effects using the Web Audio API - makes everything feel 10x better
- Intermediate: Create multiple levels with different enemy types and power-ups
- Advanced: Add multiplayer using WebSockets for local network play
Tools I Actually Use
- VS Code: Best JavaScript debugging, use the Live Server extension
- Chrome DevTools: Performance tab shows you exactly what's slowing down
- Canvas API Documentation: MDN Web Docs - bookmark the 2D context reference
Personal tip: Start with this exact code, then modify one small thing at a time. I've seen too many beginners change 5 things at once and spend hours debugging.