1Animation and the Game Loop
▶
Every interactive game or animation you see running in a browser window shares one foundational mechanism: a game loop. Without it, a canvas-based game would paint a single frame and go silent. With it, the game breathes — objects move, collisions are tested, scores change, and the screen refreshes dozens of times every second. Understanding how to build and control this loop is the single most important skill in browser-based game development.
At its core, a game loop is a cycle that runs repeatedly for as long as the game is active. Each iteration of the cycle does exactly two things in order: it updates game state (moving objects, checking collisions, tracking score, advancing animations) and then renders the updated state to the canvas (clearing the old frame and drawing every object in its new position). Separating these two concerns — update first, draw second — keeps your code organised and dramatically easier to debug. If a ball is teleporting to the wrong place, you know to look in your update logic. If a ball is drawn in the wrong colour, you look in your draw logic.
Because JavaScript running in a browser is single-threaded and event-driven, you cannot write an infinite while(true) loop; it would lock the browser completely. Instead, browsers provide a purpose-built scheduling API called requestAnimationFrame.
Introducing requestAnimationFrame
window.requestAnimationFrame(callback) tells the browser: "Before you paint the next frame to the screen, please call this function." The browser synchronises your callback with its own repaint cycle — typically 60 times per second on a 60 Hz display, or up to 144 times per second on high-refresh-rate monitors. This synchronisation is what makes animations silky smooth and avoids the screen-tearing that can happen with timer-based approaches like setInterval.
A critical detail: each call to requestAnimationFrame schedules exactly one future callback. It does not loop on its own. To keep the animation running continuously, your callback must call requestAnimationFrame again before it returns. If it forgets to do so, the animation simply stops after a single frame.
The browser automatically passes a high-resolution timestamp (a DOMHighResTimeStamp measured in milliseconds, with sub-millisecond precision) as the first argument to your callback. This timestamp records the moment the frame began, and you will use it to measure how much time has elapsed between frames — a concept explored in detail below.
// Minimal skeleton — scheduling one frame at a time
function gameLoop(timestamp) {
// 1. Update game state
// 2. Draw everything
// 3. Schedule the next frame
requestAnimationFrame(gameLoop);
}
// Kick off the very first frame
requestAnimationFrame(gameLoop);
Writing the Core Loop Function
Give your loop function a proper name (such as gameLoop) rather than using an anonymous arrow function. Because the loop must call itself to schedule the next iteration, a named function can reference itself by name. An anonymous function stored in a variable can also work, but a declared function is clearer and avoids subtle closure issues.
At the very start of the drawing step, always clear the entire canvas with ctx.clearRect(0, 0, canvas.width, canvas.height). Skipping this leaves "ghost" images from the previous frame behind — every frame gets painted on top of all previous frames, producing a smeared trail rather than smooth movement.
Keep the loop function itself short. Delegate real work to clearly named helper functions — conventionally called update() and draw() — and call those helpers from inside the loop. This pattern makes each piece individually testable and keeps the top-level loop readable at a glance.
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Game state
const ball = { x: 100, y: 100, vx: 3, vy: 2, radius: 15 };
function update() {
ball.x += ball.vx;
ball.y += ball.vy;
// Bounce off left/right walls
if (ball.x - ball.radius < 0 || ball.x + ball.radius > canvas.width) {
ball.vx *= -1;
}
// Bounce off top/bottom walls
if (ball.y - ball.radius < 0 || ball.y + ball.radius > canvas.height) {
ball.vy *= -1;
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fillStyle = '#e74c3c';
ctx.fill();
}
function gameLoop(timestamp) {
update();
draw();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
In the example above, the loop is clean and the responsibilities are obvious: update handles physics and boundary logic, draw handles rendering, and gameLoop simply orchestrates the two in the correct order.
Updating Game State Each Frame
The most common way to move an object is to give it velocity properties (vx for horizontal speed, vy for vertical speed) and add them to the object's coordinates once per frame. If ball.vx is 3, then every frame the ball moves three canvas units to the right. Over 60 frames (one second at 60 fps), it travels 180 units — a predictable, controllable speed.
The update function is also the right place to evaluate game conditions continuously: is the player out of bounds? Has a projectile struck an enemy? Has the score reached a winning threshold? Because update runs every frame, any condition checked there is evaluated at the same frequency as movement, so you will never miss a collision because it happened "between" checks.
function update() {
// Move the player
if (keys['ArrowRight']) player.x += player.speed;
if (keys['ArrowLeft']) player.x -= player.speed;
// Clamp to canvas bounds
player.x = Math.max(0, Math.min(canvas.width - player.width, player.x));
// Check win condition
if (player.x > goalX) {
console.log('Level complete!');
}
}
Delta Time and Frame-Rate Independence
Adding a fixed velocity like ball.x += 3 works fine at 60 fps, but consider what happens on a slower machine that only manages 30 fps. At 30 fps the update function runs half as many times per second, so the ball travels only half the distance it would at 60 fps. Players on slower hardware get a slower, harder game. Players on fast 144 Hz screens get a blazingly fast, unfair game. This is the frame-rate dependency problem.
The solution is delta time (often written dt): the number of seconds (or milliseconds) that elapsed since the last frame. Instead of moving an object by a fixed pixel amount, you move it by speed × deltaTime. Because speed is now expressed in pixels-per-second and multiplied by the actual time elapsed, the total distance covered per second stays constant regardless of frame rate.
To calculate delta time, store the timestamp received in the previous frame, then subtract it from the current timestamp:
let lastTimestamp = null;
function gameLoop(timestamp) {
// On the very first frame, lastTimestamp is null, so delta defaults to 0
const dt = lastTimestamp !== null ? (timestamp - lastTimestamp) / 1000 : 0;
lastTimestamp = timestamp;
update(dt);
draw();
requestAnimationFrame(gameLoop);
}
// Speed expressed as pixels per SECOND
const BALL_SPEED = 200; // 200 px/s
function update(dt) {
ball.x += ball.vx * BALL_SPEED * dt;
ball.y += ball.vy * BALL_SPEED * dt;
}
At 60 fps, dt ≈ 0.0167 s, so the ball moves 200 × 0.0167 ≈ 3.33 px per frame. At 30 fps, dt ≈ 0.0333 s, so it moves 200 × 0.0333 ≈ 6.67 px per frame — twice as far per frame, but only half as many frames. The result in both cases: the ball travels approximately 200 pixels per second.
One important safeguard: if a browser tab is hidden or backgrounded, requestAnimationFrame pauses automatically (a feature, not a bug). When the tab becomes visible again, the next timestamp will be hundreds or thousands of milliseconds after the last one, producing a giant dt that launches objects across the screen in a single frame. Always clamp delta time to a safe maximum — a common choice is 100 ms (0.1 s):
const MAX_DT = 0.1; // seconds
function gameLoop(timestamp) {
let dt = lastTimestamp !== null ? (timestamp - lastTimestamp) / 1000 : 0;
dt = Math.min(dt, MAX_DT); // prevent huge jumps after tab switch
lastTimestamp = timestamp;
update(dt);
draw();
requestAnimationFrame(gameLoop);
}
The table below summarises what happens to object movement at various frame rates, both with and without delta-time correction, for an object with a target speed of 180 pixels per second:
| Frame Rate | Frames per second | Movement without delta time (px/frame fixed at 3) | Total px/second (no dt) | dt per frame (s) | Movement with delta time (180 px/s × dt) | Total px/second (with dt) |
|---|---|---|---|---|---|---|
| 30 fps | 30 | 3 px | 90 px/s | ≈ 0.0333 | ≈ 6 px | 180 px/s |
| 60 fps | 60 | 3 px | 180 px/s | ≈ 0.0167 | ≈ 3 px | 180 px/s |
| 120 fps | 120 | 3 px | 360 px/s | ≈ 0.0083 | ≈ 1.5 px | 180 px/s |
| 144 fps | 144 | 3 px | 432 px/s | ≈ 0.0069 | ≈ 1.25 px | 180 px/s |
The pattern is clear: without delta time, faster hardware gives faster movement. With delta time, movement speed is consistent across all frame rates.
Starting and Stopping the Loop
The loop is started with a single initial call to requestAnimationFrame(gameLoop). This must happen after all game assets are loaded, all variables are initialised, and the canvas is ready. Calling it too early (before images have loaded, for example) means your first frames may draw placeholder or missing content.
requestAnimationFrame returns a numeric frame ID — a handle that uniquely identifies the pending callback registration. Saving this ID is what allows you to stop the loop:
let frameId;
function gameLoop(timestamp) {
update();
draw();
frameId = requestAnimationFrame(gameLoop); // save the new ID every frame
}
function startGame() {
frameId = requestAnimationFrame(gameLoop);
}
function pauseGame() {
cancelAnimationFrame(frameId); // the loop stops after the current frame
}
function resumeGame() {
lastTimestamp = null; // reset dt so there's no huge jump on resume
frameId = requestAnimationFrame(gameLoop);
}
cancelAnimationFrame(frameId) tells the browser to cancel the pending callback. Because the loop re-schedules itself at the end of every iteration, cancelling the most recent scheduled ID is all that is needed to halt the entire loop — it simply never schedules another frame after the current one finishes. Note that you cannot "resume" a cancelled callback; you must make a fresh call to requestAnimationFrame to restart scheduling, just as you did when first starting the game.
A complete, well-structured example tying all concepts together:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const ball = { x: 50, y: 150, radius: 20, speed: 180 }; // speed in px/s
let directionX = 1;
let lastTimestamp = null;
let frameId = null;
let running = false;
function update(dt) {
ball.x += ball.speed * directionX * dt;
if (ball.x + ball.radius >= canvas.width || ball.x - ball.radius <= 0) {
directionX *= -1;
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fillStyle = '#2980b9';
ctx.fill();
}
function gameLoop(timestamp) {
let dt = lastTimestamp !== null ? (timestamp - lastTimestamp) / 1000 : 0;
dt = Math.min(dt, 0.1); // clamp to 100 ms max
lastTimestamp = timestamp;
update(dt);
draw();
frameId = requestAnimationFrame(gameLoop);
}
function start() {
if (!running) {
running = true;
lastTimestamp = null;
frameId = requestAnimationFrame(gameLoop);
}
}
function stop() {
if (running) {
running = false;
cancelAnimationFrame(frameId);
}
}
// Begin on page load
start();
This example demonstrates every principle from this topic: a named loop function, separated update and draw helpers, delta-time calculation with clamping, frame ID management, and clean start/stop control. A ball bounces horizontally at a consistent 180 pixels per second regardless of the display's refresh rate — the hallmark of a correctly implemented game loop.