Welcome to the interactive Dodge tutorial. Here, you’ll learn how to build the classic Dodge game step-by-step. In this guide, we will use React to handle state and the HTML5 <canvas> API for high-performance rendering.
This tutorial is a companion to the Dodge lab post, which shows the finished restoration and gives the project context. Read that first if you want to see the completed game before building it from scratch here.
Let’s dive in.
1. The Canvas Setup
Every game starts with two pieces: a surface to draw on, and a loop that redraws that surface as the world changes. In this first chapter, we only draw one static frame so the Canvas basics are clear before movement gets involved.
A <canvas> is a DOM element, but the drawing API lives on its rendering context. React renders the element; the Canvas API draws pixels inside it. That is why we use useRef: it gives us access to the actual canvas node after React mounts it. Inside useEffect, canvasRef.current is available, so we can ask for the 2D drawing context with getContext('2d').
Canvas is an immediate-mode API. When you call fillRect, pixels are painted right away. Canvas does not remember a rectangle object for you the way the DOM remembers a <div>. If you want the frame to change later, you clear or repaint the canvas yourself.
import { useRef, useEffect } from 'react';
export default function Game() {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 1. Draw the background
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, []);
return <canvas ref={canvasRef} width={800} height={800} />;
}Next, we draw the ship. A Canvas path is like moving a pen around the canvas: beginPath starts a new shape, moveTo lifts the pen to the first point, lineTo draws edges, and closePath connects the last point back to the first.
The transform calls are the part that usually feels mysterious at first. We draw the triangle around (0, 0) as if the ship’s local origin is its center. The tip is at (20, 0), so before rotation the triangle points along the positive x-axis. ctx.translate(canvas.width / 2, canvas.height / 2) moves that local origin to the center of the canvas. ctx.rotate(-Math.PI / 2) rotates the local coordinate system so the triangle points upward.
ctx.save() and ctx.restore() protect the rest of the drawing code. translate, rotate, strokeStyle, and lineWidth all belong to Canvas’s current drawing state. Saving before the ship and restoring afterward means future shapes will not accidentally inherit the ship’s position, rotation, or style.
// 2. Draw the Ship
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2); // Move to center
ctx.rotate(-Math.PI / 2); // Point upwards
ctx.beginPath();
ctx.moveTo(20, 0); // Tip
ctx.lineTo(-10, 10); // Bottom right
ctx.lineTo(-10, -10); // Bottom left
ctx.closePath();
ctx.strokeStyle = '#818cf8';
ctx.lineWidth = 4;
ctx.stroke();
ctx.restore();Checkpoint: You should be able to explain that React creates the canvas element, useRef gives us the DOM node, getContext('2d') gives us drawing commands, and the ship is drawn in its own translated and rotated coordinate system.
2. Player Controls
To make the ship move, we need input, state, and a loop. The input comes from the keyboard. The state is the ship’s position, velocity, and angle. The loop reads the input, updates the state, clears the canvas, and draws the next frame.
In React, game state that changes every frame should usually live in a useRef, not useState. useState is for values that should trigger a React render. The ship’s x, y, vx, and vy values change around 60 times per second, but React does not need to re-render the component for each tiny movement. The canvas redraw already handles the visual update.
We attach event listeners to track the keydown and keyup events. To prevent the browser page from scrolling when you press the arrow keys or space, we call e.preventDefault():
const playerRef = useRef({
x: 400, y: 400,
vx: 0, vy: 0,
angle: -Math.PI / 2
});
const keysRef = useRef({});
useEffect(() => {
const down = (e) => {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
e.preventDefault();
}
keysRef.current[e.code] = true;
};
const up = (e) => keysRef.current[e.code] = false;
const clearKeys = () => {
keysRef.current = {};
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
window.addEventListener('blur', clearKeys);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
window.removeEventListener('blur', clearKeys);
};
}, []);The keysRef object stores which keys are currently held down. On keydown, we mark that key as true; on keyup, we mark it as false. We also clear the map on blur, because a browser can miss keyup if the player tabs away while holding a key. The cleanup function removes the listeners when the component unmounts, which matters because the page can navigate away and back without a full browser refresh.
Inside the game loop, key input changes velocity, not position directly. Pressing right adds to vx; pressing up subtracts from vy. Then position is updated from velocity with p.x += p.vx and p.y += p.vy. This two-step model gives the ship inertia: the controls apply thrust, and friction gradually slows it down when the player lets go.
const updatePlayer = (p, keys, width, height) => {
// Keyboard input changes velocity, not position.
if (keys['ArrowUp'] || keys['KeyW']) p.vy -= 0.3;
if (keys['ArrowDown'] || keys['KeyS']) p.vy += 0.3;
if (keys['ArrowLeft'] || keys['KeyA']) p.vx -= 0.3;
if (keys['ArrowRight'] || keys['KeyD']) p.vx += 0.3;
// Friction keeps the ship from sliding forever.
p.vx *= 0.95;
p.vy *= 0.95;
// Velocity becomes movement.
p.x += p.vx;
p.y += p.vy;
if (p.x < 0) p.x = width;
if (p.x > width) p.x = 0;
if (p.y < 0) p.y = height;
if (p.y > height) p.y = 0;
if (Math.abs(p.vx) > 0.1 || Math.abs(p.vy) > 0.1) {
p.angle = Math.atan2(p.vy, p.vx);
}
};Edge wrapping keeps the ship inside the playfield without a hard wall. If the ship moves past the right edge, it appears at x = 0; if it moves above the top edge, it appears at the bottom. Finally, Math.atan2(vy, vx) converts the velocity direction into an angle, so the triangle points where it is moving.
Checkpoint: You should be able to trace one frame: read held keys, adjust velocity, apply friction, move the ship, wrap at edges, rotate toward movement, and draw the ship again.
3. Enemies & Movement
A dodge game needs something to dodge. In this chapter, each enemy is just a small object with position, velocity, radius, and color. That is enough data to update it and draw it every frame.
We store the dots in an array inside a useRef for the same reason we stored the player in a ref: the array changes inside the animation loop, and we do not want React to re-render for every dot movement. Each dot starts at a random location and gets a random velocity. Math.random() - 0.5 gives a value between -0.5 and 0.5, so multiplying by 2 gives motion in either direction.
const dotsRef = useRef([]);
// Initialize 20 dots
for (let i = 0; i < 20; i++) {
dotsRef.current.push({
x: Math.random() * 800,
y: Math.random() * 800,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
radius: 3,
color: '#ffffff'
});
}Inside the requestAnimationFrame loop, we iterate through dotsRef.current. For each dot, we update its position by adding velocity (x += vx, y += vy). Then we render it as a circle with ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2). The last two arguments describe a full circle in radians: from angle 0 to 2 * PI.
If a dot moves off the edge of the screen, we don’t delete it. Instead, we simply wrap its coordinates to the opposite side of the canvas. This is an old arcade trick that keeps the screen permanently populated without having to run an expensive spawning/despawning loop.
Checkpoint: You should be able to describe a dot as data, explain why the dots live in a ref, and follow the update order: move dot, wrap dot, draw dot, then draw the player on top.
4. Gravity & Physics
The unique mechanic of Dodge is that the player acts like a magnet. The ship does not directly pull dots by magic; each dot calculates a direction vector pointing from itself to the ship, then adds a small amount of that direction to its velocity.
The vector starts with two differences: dx = player.x - dot.x and dy = player.y - dot.y. Together, those describe the offset from the dot to the ship. The distance is the length of that offset, which we calculate with the Pythagorean theorem: sqrt(dx * dx + dy * dy).
If the distance is within our GRAVITY_RADIUS, we calculate a linear falloff force: the closer the dot is, the stronger the pull. This is not physically accurate gravity, but it gives us a controllable game-feel version of attraction.
const GRAVITY_RADIUS = 180;
const GRAVITY_STRENGTH = 0.045;
dots.forEach(dot => {
const dx = player.x - dot.x;
const dy = player.y - dot.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < GRAVITY_RADIUS) {
// Calculate linear falloff: closer means stronger pull
const force = ((GRAVITY_RADIUS - dist) / GRAVITY_RADIUS) * GRAVITY_STRENGTH;
// Apply force along the normalized directional vector
dot.vx += (dx / dist) * force;
dot.vy += (dy / dist) * force;
// Draw a tether line for visual feedback
ctx.beginPath();
ctx.moveTo(player.x, player.y);
ctx.lineTo(dot.x, dot.y);
ctx.strokeStyle = "rgba(99,102,241," + (0.6 * (GRAVITY_RADIUS-dist)/GRAVITY_RADIUS) + ")";
ctx.stroke();
}
});Notice how we divide dx and dy by dist. That normalizes the vector: it keeps the direction but changes the length to 1. Once the direction has length 1, we can multiply it by force and add it to the dot’s velocity. We also check dist > 0 first, because dividing by zero would create invalid velocity values.
This creates a slingshot effect. If you stand still, enemies will cluster and crash into you. If you keep moving, you can whip them behind you in an orbit!
The tether line is not part of the physics. It is feedback for the player. It makes the invisible force visible, and its opacity uses the same falloff idea: stronger pull means a more visible line.
Checkpoint: You should be able to explain dx, dy, dist, why we normalize by dividing by dist, why dist > 0 matters, and how adding force to velocity creates attraction over time.
5. Collision & Polish
By this point, the tutorial already has the main helper-function shape: updatePlayer moves the ship, drawPlayer renders it, and updateAndDrawDots owns enemy behavior. Chapter 5 does not introduce a new architecture. It adds a game lifecycle on top of the loop we already built.
There are two new jobs:
- Detect when a dot hits the ship.
- Use game state to decide whether we are showing the start screen, running the loop, or showing game over.
The game state has three values:
const [gameState, setGameState] = useState('START');
const statusRef = useRef('START');
const rafRef = useRef(null);gameState is React state. We use it for UI because changing it should re-render the overlay. statusRef stores the same lifecycle value for the animation loop and keyboard handler. Those functions run outside React’s render cycle, so a ref gives them the latest value immediately. rafRef stores the current animation-frame id so we can cancel an old loop before starting a new one.
The ship is drawn as a triangle, but for collision we use an approximate circle around it. That is a common game-development tradeoff: the collision shape does not have to match the artwork perfectly as long as it feels fair. Each dot already has a radius, and we already calculate dist in the gravity step. If the distance between centers is smaller than shipRadius + dot.radius, the two hit circles overlap.
const updateAndDrawDots = (ctx, dots, player, width, height) => {
let crashed = false;
dots.forEach((dot) => {
const dx = player.x - dot.x;
const dy = player.y - dot.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Gravity, movement, wrapping, and drawing stay here too.
if (dist < 12 + dot.radius) {
crashed = true;
}
});
return crashed;
};Notice that updateAndDrawDots does not call setGameState directly. It only reports what happened during this frame by returning true or false. That keeps the helper focused: it updates and draws dots, then tells the main loop whether the player crashed.
The main loop is the right place to decide what the crash means. If there is no crash, schedule the next frame. If there is a crash, change both lifecycle values to 'GAMEOVER' and do not schedule another frame. That freezes the canvas on the crash moment.
const loop = () => {
if (statusRef.current !== 'PLAYING') return;
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
updatePlayer(pRef.current, kRef.current, canvas.width, canvas.height);
const crashed = updateAndDrawDots(
ctx,
dotsRef.current,
pRef.current,
canvas.width,
canvas.height
);
drawPlayer(ctx, pRef.current);
if (crashed) {
statusRef.current = 'GAMEOVER';
setGameState('GAMEOVER');
} else {
rafRef.current = requestAnimationFrame(loop);
}
};Starting or restarting the game resets the simulation. We clear any held-key state, center the player, create a fresh dot array, mark the lifecycle as 'PLAYING', and then start the loop. Clearing the key map matters because browsers can miss a keyup event if the iframe or tab loses focus while a key is held.
const startGame = () => {
const canvas = canvasRef.current;
if (!canvas) return;
kRef.current = {};
pRef.current = {
x: canvas.width / 2,
y: canvas.height / 2,
vx: 0,
vy: 0,
a: 0
};
dotsRef.current = Array.from({ length: 25 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
radius: 3
}));
statusRef.current = 'PLAYING';
setGameState('PLAYING');
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(loop);
};The keyboard handler uses the lifecycle too. Space should start from the title screen or restart from the crash screen, but it should not restart the game while the player is already playing.
const down = (e) => {
if (e.code === 'Space' && statusRef.current !== 'PLAYING') {
startGame();
}
};Finally, React uses gameState to render the overlay. When the game is 'PLAYING', the overlay is hidden. When the game is 'START', the overlay says “DODGE”. When the game is 'GAMEOVER', the same overlay changes to “CRASHED”.
{gameState !== 'PLAYING' && (
<div style={overlayStyle}>
<h2>{gameState === 'START' ? 'DODGE' : 'CRASHED'}</h2>
<p>Press SPACE to {gameState === 'START' ? 'Start' : 'Retry'}</p>
</div>
)}With that, the core mechanics of Dodge are complete. You can enhance the game further by adding a score timer, high score tracking, particle explosion effects when you crash, or mobile touch controls.
Checkpoint: You should be able to explain why collision can reuse dist, why the dot helper returns crashed, how 'START', 'PLAYING', and 'GAMEOVER' control the game, and why gameState and statusRef serve different parts of the app.