Magyar zászlóMagyar

Practice 7 - Canvas Animation & Basic Physics

2025-04-03 6 min read GitHub

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 },
];
PropertyMeaning
x, yPosition of the ball’s centre in canvas pixels
vVertical velocity (pixels per millisecond) — starts at 0
colorFill colour — any CSS colour string
rRadius 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)
ParameterMeaning
cx, cyCentre of the circle
rRadius
startAngleStart of the arc, in radians — 0 is the 3 o’clock position
endAngleEnd 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 2π2\pi 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
  • requestAnimationFrame automatically 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: Δy=vΔt\Delta y = v \cdot \Delta t, and Δv=aΔt\Delta v = a \cdot \Delta t. 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:

  1. Velocity reversesv *= -1 sends the ball back upward with the same speed
  2. Position is clampedball.y = canvas.height - ball.r places 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

ConceptAPI / note
Draw a circlectx.arc(cx, cy, r, 0, 2 * Math.PI) then ctx.fill()
Clear the canvas each framectx.clearRect(0, 0, canvas.width, canvas.height)
Animation looprequestAnimationFrame(callback) — browser-synced, pauses when tab hidden
Delta timeperformance.now() — elapsed ms between frames
Frame-rate independent physicsmultiply all velocities/accelerations by dt
Guard against large dtif (dt > 100) return;
Gravityball.v += acceleration * dt
Bouncereverse v, clamp position to floor
Click coordinates on canvasevent.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)})`