Retro games are making a comeback—sometimes for nostalgia, sometimes for their delightful simplicity. In this article, we’ll walk through how to build a small-scale 2D game using HTML5 Canvas and vanilla JavaScript. We’ll cover setting up the project, drawing shapes and sprites, handling input, detecting collisions, and more.
Table of Contents
- Project Setup
- Basic Canvas and Game Loop
- Sprites and Movement
- Input Handling
- Collision Detection and Game States
- Simple Retro Effects
- Sound Effects and Music (Optional)
- Enhancements and Next Step
- Complete Example
- Conclusion
1. Project Setup
Create Your Project Folder
- Create a new folder, for example,
retro-game
. - Inside, add:
index.html
main.js
style.css
(optional for styling or UI elements).
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Retro 2D Game</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<canvas id="gameCanvas" width="400" height="400"></canvas>
<script src="main.js"></script>
</body>
</html>
style.css
(Optional)
body {
margin: 0;
padding: 0;
background-color: #333;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
#gameCanvas {
background-color: #000;
/* This ensures pixelated scaling for a retro feel */
image-rendering: pixelated;
}
2. Basic Canvas and Game Loop
Access the Canvas
In main.js
, grab references to your canvas and its 2D context:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
Game Loop Overview
A core component of any 2D game is the game loop, which continuously:
- Updates the game state and logic (positions, collisions, etc.)
- Draws the current frame
We can use requestAnimationFrame()
to keep this loop running smoothly:
function gameLoop() {
update();
draw();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
Clearing the Canvas
Each time we draw a new frame, we should clear out the old one:
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
3. Sprites and Movement
Loading Sprites (Optional)
If you’d rather not rely on solid shapes, load a sprite sheet or simple image:
const sprite = new Image();
sprite.src = 'path/to/sprite.png';
But for a minimalistic retro look, rectangles and pixels often suffice.
Tracking Positions
Here’s a simple player object:
const player = {
x: 50,
y: 50,
width: 16,
height: 16,
speed: 2,
};
Updating Movement
In our update()
function, we can move the player:
function update() {
// Example: Move player automatically to the right
player.x += player.speed;
// Wrap around screen for a "retro" effect
if (player.x > canvas.width) {
player.x = -player.width;
}
}
Drawing the Player
Inside draw()
, we can visualize the player:
Option A: Rectangle
ctx.fillStyle = '#0f0';
ctx.fillRect(player.x, player.y, player.width, player.height);
Option B: Sprite
ctx.drawImage(sprite, player.x, player.y, player.width, player.height);
4. Input Handling
Keyboard Events
We can track keyboard presses using event listeners and a keys
object:
const keys = {};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
});
window.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
function update() {
if (keys['ArrowUp'] || keys['KeyW']) {
player.y -= player.speed;
}
if (keys['ArrowDown'] || keys['KeyS']) {
player.y += player.speed;
}
if (keys['ArrowLeft'] || keys['KeyA']) {
player.x -= player.speed;
}
if (keys['ArrowRight'] || keys['KeyD']) {
player.x += player.speed;
}
// Additional logic like collision checks, etc.
}
5. Collision Detection and Game States
Basic Collision
A quick rectangle collision check between two objects:
function isColliding(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
Game States
It’s common to have multiple game states—like “start,” “playing,” “over.”
let gameState = 'start';
function update() {
if (gameState === 'playing') {
// Update positions, collisions, etc.
}
}
function draw() {
clearCanvas();
if (gameState === 'start') {
// Draw start screen
} else if (gameState === 'playing') {
// Draw main game
} else if (gameState === 'over') {
// Draw game over screen
}
}
6. Simple Retro Effects
Pixel Scaling
To emulate an older-style pixel look:
- Use small sprite sizes (e.g. 16×16).
- Scale up the displayed size using CSS:
#gameCanvas {
width: 400px; /* displayed size */
height: 400px;
image-rendering: pixelated;
}
- Keep the canvas’s actual resolution relatively small (e.g., 200×200).
Fixed Frame Rate
Old-school games often ran at fixed frame rates (e.g., 60 FPS). You can mimic this:
let lastTime = 0;
const fps = 60;
const frameDuration = 1000 / fps;
function gameLoop(timestamp) {
const delta = timestamp - lastTime;
if (delta > frameDuration) {
lastTime = timestamp - (delta % frameDuration);
update();
draw();
}
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
This ensures each update occurs at a stable rhythm.
7. Sound Effects and Music (Optional)
Loading Audio
const jumpSound = new Audio('sounds/jump.wav');
const backgroundMusic = new Audio('sounds/music.mp3');
backgroundMusic.loop = true;
Playing Sounds
function jump() {
jumpSound.currentTime = 0;
jumpSound.play();
}
// Start the music if needed
backgroundMusic.play();
Use events like “key press” or “collision” to trigger a sound.
8. Enhancements in Code
- Enemies – Store multiple enemies in an array, each with its own position and movement logic.
- Scoring – Increment a score counter on successful events (e.g., hitting a target).
- UI Elements – Display messages, menus, or a HUD by drawing text on the canvas or using HTML overlays.
- Refactoring – As your code grows, consider using classes or modules to separate logic (e.g.,
Player
,Enemy
,GameWorld
).
9. Complete Example
Here’s a more cohesive snippet demonstrating the ideas mentioned:
// main.js
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const keys = {};
let gameState = 'start';
let score = 0;
const player = {
x: 50,
y: 50,
width: 16,
height: 16,
speed: 2,
};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
// If we're on the start screen, press Enter to begin
if (e.code === 'Enter' && gameState === 'start') {
gameState = 'playing';
}
});
window.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
function isColliding(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function update() {
if (gameState === 'playing') {
// Player movement
if (keys['ArrowUp'] || keys['KeyW']) player.y -= player.speed;
if (keys['ArrowDown'] || keys['KeyS']) player.y += player.speed;
if (keys['ArrowLeft'] || keys['KeyA']) player.x -= player.speed;
if (keys['ArrowRight'] || keys['KeyD']) player.x += player.speed;
// Screen wrap
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;
// Simple score increment
score++;
}
}
function draw() {
clearCanvas();
if (gameState === 'start') {
ctx.fillStyle = '#fff';
ctx.font = '20px monospace';
ctx.fillText('Press Enter to Start', 50, 100);
} else if (gameState === 'playing') {
// Draw player
ctx.fillStyle = '#0f0';
ctx.fillRect(player.x, player.y, player.width, player.height);
// Draw score
ctx.fillStyle = '#fff';
ctx.font = '16px monospace';
ctx.fillText(`Score: ${score}`, 10, 20);
} else if (gameState === 'over') {
ctx.fillStyle = '#fff';
ctx.font = '20px monospace';
ctx.fillText('Game Over!', 100, 100);
}
}
function gameLoop() {
update();
draw();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
Conclusion
By understanding the basics of the HTML5 Canvas, requestAnimationFrame loops, and key input handling, you have the foundation for creating a wide variety of 2D games—from small arcade clones to more ambitious retro-inspired adventures. Experiment with sprite art, physics, sound design, and user interface to bring your game to life. As you enhance and expand your code, consider adopting object-oriented patterns or game development libraries (e.g., Phaser or PixiJS) to streamline your workflow.