Practice 7 - Canvas Animation & Basic Physics
Introduction
Last session we drew static shapes on a canvas: rectangles, text, images, and paths. Everything was drawn once and stayed there.
This session we make things move. We will build a simple physics simulation: balls that fall under gravity, bounce off the floor, and can be added by clicking the canvas. Along the way you will learn:
- How to animate on canvas with
requestAnimationFrame - What delta time is and why it matters
- How to draw circles with
ctx.arc() - How to keep a list of moving objects in state and update them each frame
Project structure
practice-07/
├── index.html
└── main.js
The HTML is minimal — a 500×500 canvas with a border:
<canvas width="500" height="500"></canvas>
<script src="./main.js"></script>
1. State — representing the balls
Each ball is a plain object with a position, a vertical velocity, a colour, and a radius:
let balls = [
{ x: 50, y: 50, v: 0, color: "red", r: 10 },
{ x: 100, y: 150, v: 0, color: "blue", r: 20 },
];
| Property | Meaning |
|---|---|
x, y | Position of the ball’s centre in canvas pixels |
v | Vertical velocity (pixels per millisecond) — starts at 0 |
color | Fill colour — any CSS colour string |
r | Radius in pixels |
All the balls live in the balls array. Everything the program does — drawing and physics — reads and writes this array.
2. Drawing — circles with ctx.arc()
Last session we drew rectangles with fillRect and shapes with lineTo. Circles use ctx.arc(), which is part of the path API:
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const ball of balls) {
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, 2 * Math.PI);
ctx.fillStyle = ball.color;
ctx.fill();
}
}
ctx.arc(x, y, radius, startAngle, endAngle)
ctx.arc(cx, cy, r, startAngle, endAngle)
| Parameter | Meaning |
|---|---|
cx, cy | Centre of the circle |
r | Radius |
startAngle | Start of the arc, in radians — 0 is the 3 o’clock position |
endAngle | End of the arc — 2 * Math.PI completes the full circle |
A full circle, on the path API, looks like this:
ctx.beginPath();
ctx.arc(100, 100, 30, 0, 2 * Math.PI); // full circle centred at (100, 100)
ctx.fill(); // or ctx.stroke() for an outline
The angle is measured in radians, not degrees. The full circle is radians. If you ever need just a half-circle it would be Math.PI, a quarter-circle Math.PI / 2, etc.
Why clearRect every frame?
Canvas has no memory of what was drawn. If you just draw new circles without clearing, the old positions stay painted — leaving a smear trail. clearRect(0, 0, canvas.width, canvas.height) wipes the entire canvas to transparent before each frame.
3. The animation loop — requestAnimationFrame
A static draw() call produces a single frame. To animate, we need to draw a new frame continuously. The browser provides requestAnimationFrame (rAF) for this:
function gameLoop() {
draw();
update(dt);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
requestAnimationFrame(callback) tells the browser: “call this function just before painting the next frame”. Most displays run at 60 fps, so the callback fires roughly every 16 ms. Because each call reschedules the next, the loop runs indefinitely.
Why not setInterval?
setInterval(fn, 16) would also call a function every ~16 ms, but it has important disadvantages:
- It fires even when the tab is hidden, wasting CPU and battery
- It is not synchronised to the display’s refresh rate, causing visual tearing
requestAnimationFrameautomatically pauses when the tab is not visible and resumes cleanly when the user returns
4. Delta time — making physics frame-rate independent
If the physics update just adds a fixed amount to y every frame, the simulation runs at different speeds on different machines (a 30 fps device moves balls half as fast as a 60 fps device). The fix is delta time: the actual elapsed time since the last frame.
let last = performance.now();
function gameLoop() {
const now = performance.now();
const dt = now - last; // milliseconds since last frame
draw();
update(dt);
last = now;
requestAnimationFrame(gameLoop);
}
performance.now() returns the current time in milliseconds with sub-millisecond precision. dt (delta time) is how many ms have passed since the previous frame — typically 16–17 ms at 60 fps.
All physics calculations are then multiplied by dt, so the result is in “units per millisecond” rather than “units per frame”:
ball.v += 0.0001 * dt; // gravity — acceleration in px/ms²
ball.y += ball.v * dt; // position — velocity × time
This way a ball moves the same distance per second regardless of frame rate.
The relationship is the standard kinematic formula: , and . Canvas physics is just discrete integration of these over small time steps.
Guarding against large dt spikes
If the user switches tabs and comes back, the browser pauses rAF. When it resumes, dt could be several seconds, catapulting every ball off-screen in one jump. A simple guard prevents this:
function update(dt) {
if (dt > 100) return; // skip frames longer than 100 ms
// ...
}
5. Physics — gravity and bouncing
function update(dt) {
if (dt > 100) return;
for (const ball of balls) {
// Bounce off the bottom edge
if (ball.y + ball.r > canvas.height) {
ball.v *= -1;
ball.y = canvas.height - ball.r;
}
// Gravity: accelerate downward
ball.v += 0.0001 * dt;
// Move the ball
ball.y += ball.v * dt;
}
}
Gravity
ball.v += 0.0001 * dt;
Each frame, the downward velocity increases by a small amount proportional to elapsed time. The constant 0.0001 is the gravitational acceleration in px/ms². It is small because dt is in milliseconds.
Bouncing
if (ball.y + ball.r > canvas.height) {
ball.v *= -1;
ball.y = canvas.height - ball.r;
}
When the bottom edge of the ball (ball.y + ball.r) passes the canvas floor (canvas.height), two things happen:
- Velocity reverses —
v *= -1sends the ball back upward with the same speed - Position is clamped —
ball.y = canvas.height - ball.rplaces the ball exactly on the floor, preventing it from sinking through due to accumulated floating-point error over frames
Real-world bouncing loses energy on each bounce. To simulate that, multiply by a factor less than 1 instead of -1: ball.v *= -0.8. With 0.8, the ball retains 80% of its speed after each bounce and eventually comes to rest.
6. Adding balls by clicking
canvas.addEventListener("click", function(event) {
const ball = {
x: event.offsetX,
y: event.offsetY,
v: 0,
r: Math.floor(10 + Math.random() * 10),
color: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)})`
};
balls.push(ball);
});
event.offsetX / event.offsetY give the click coordinates relative to the canvas element — exactly the same coordinate system used for drawing. No conversion needed.
Random values
r: Math.floor(10 + Math.random() * 10)
Math.random() returns a float in [0, 1). Scaling gives [0, 10), then adding 10 gives [10, 20). Math.floor rounds down to an integer, so radius is 10–19 px.
color: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)})`
Each RGB channel is a random integer from 0 to 255. The template literal assembles it into a valid CSS rgb() colour string.
Complete main.js
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
let balls = [
{ x: 50, y: 50, v: 0, color: "red", r: 10 },
{ x: 100, y: 150, v: 0, color: "blue", r: 20 },
];
// --- Draw ---
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const ball of balls) {
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, 2 * Math.PI);
ctx.fillStyle = ball.color;
ctx.fill();
}
}
// --- Physics ---
function update(dt) {
if (dt > 100) return;
for (const ball of balls) {
if (ball.y + ball.r > canvas.height) {
ball.v *= -1;
ball.y = canvas.height - ball.r;
}
ball.v += 0.0001 * dt;
ball.y += ball.v * dt;
}
}
// --- Animation loop ---
let last = performance.now();
function gameLoop() {
const now = performance.now();
const dt = now - last;
draw();
update(dt);
last = now;
requestAnimationFrame(gameLoop);
}
gameLoop();
// --- Add balls by clicking ---
canvas.addEventListener("click", function(event) {
balls.push({
x: event.offsetX,
y: event.offsetY,
v: 0,
r: Math.floor(10 + Math.random() * 10),
color: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)})`
});
});
The animation loop — visualised
requestAnimationFrame(gameLoop)
│
▼ (browser is ready to paint)
gameLoop()
├── measure dt
├── draw() → clearRect + arc × N
├── update() → gravity + bounce × N
├── last = now
└── requestAnimationFrame(gameLoop) → schedule next frame
Each call to gameLoop takes a snapshot of the current time, renders the current state, advances the simulation, then hands control back to the browser and asks to be called again.
Summary
| Concept | API / note |
|---|---|
| Draw a circle | ctx.arc(cx, cy, r, 0, 2 * Math.PI) then ctx.fill() |
| Clear the canvas each frame | ctx.clearRect(0, 0, canvas.width, canvas.height) |
| Animation loop | requestAnimationFrame(callback) — browser-synced, pauses when tab hidden |
| Delta time | performance.now() — elapsed ms between frames |
| Frame-rate independent physics | multiply all velocities/accelerations by dt |
| Guard against large dt | if (dt > 100) return; |
| Gravity | ball.v += acceleration * dt |
| Bounce | reverse v, clamp position to floor |
| Click coordinates on canvas | event.offsetX, event.offsetY |
Random integer in [a, b) | Math.floor(a + Math.random() * (b - a)) |
| Random RGB colour | `rgb(${rand(256)}, ${rand(256)}, ${rand(256)})` |