GAMR1520: Markup languages and scripting

javascript logo

Lab 6.1: Animated canvases

Part of Week 6: Game-like systems

General setup

For all lab exercises you should create a folder for the lab somewhere sensible.

Assuming you have a GAMR1520-labs folder, you should create a GAMR1520-labs/week_6 folder for this week and a GAMR1520-labs/week_6/lab_6.1 folder inside that.

JavaScript setup

Though it is possible to contain everything within one file, a JavaScript project will usually contain a collection of multiple files.

GAMR1520-labs
└─ week_6
    └─ lab_6.1
        ├─ experiment1
        │    ├─ index.html
        │    └─ scripts.js
        └─ experiment2
             ├─ index.html
             └─ scripts.js

For simple projects, there will always be an index.html and the javascript file can always be something like scripts.js, though you can choose your own names. Using the same template for multiple examples is convenient. Try to name your folders better than this, the folder name should reflect their content. For example, blank_template, edit_elements or simple_drawing.

Resources

If you want to find out more information about any aspect of web development, the best resource is the Mozilla Developer Network web documents, in particular the JavaScript documentation will be invaluable for this module.

General approach

As you encounter new concepts, try to create examples for yourself that prove you understand what is going on. Try to break stuff, its a good way to learn. But always save a working version.

Modifying the example code is a good start, but try to write your own programmes from scratch, based on the example code. They might start very simple, but over the weeks you can develop them into more complex programmes.

Think of a programme you would like to write (don't be too ambitious). Break the problem down into small pieces and spend some time each session trying to solve a small problem.

Each of the examples in this exercise should be created as separate folders and will assume a minimal HTML document index.html containing a heading and a canvas element like this.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Example</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Example</h1>
    <canvas id="canvas" width="600" height="600"></canvas>    
    <script src="scripts.js"></script>
</body>
</html>

If the size of the canvas is too big (or small), feel free to adjust these attributes.

Notice we have added a <link> element which points to a file style.css. This file contains some styles to define ho the canvas is displayed in the browser.

body {
    background: #333;
    color: #eee;
    font-family: monospace;
    max-width: 600px;
    margin-inline: auto;
}
canvas {
    background: #eee; 
    width: 100%;
}

You can add any styles you want here - see the MDN documentation for CSS. The main reason to have these styles is to shrink the canvas on smaller devices.

Each example will also include a scripts.js file with this code at the top.

const ctx = canvas.getContext("2d");

Make sure you include this line in every example. We will assume you have it in all the examples. If you miss it off you will get an error, something like this.

Uncaught ReferenceError: ctx is not defined
   at frame (scripts.js:14:5)

In which case, you should add the above line to the top of you scripts.js file.

Create a template folder with the above files in place. For the examples below and for your own experiments you can then simply copy the template and update the titles so you can start new examples easily.

You should develop a series of folders for the examples below and your own experiments.

Development environment

You should be using VSCode for this. You will need to install the live server extension for VSCode as it makes development a lot smoother. It will automatically update the browser when you save your code and it will allow you to use JavaScript modules to organise your code.

Example 1: A simple animation

Take a copy of your template folder and give it the above title.

In the last exercise we animated a scene on an HTML canvas by defining a function to complete one frame of the animation. We then passed our function to requestAnimationFrame as an argument, and the browser did the rest for us. This is optimally efficient because it integrates our function into the browser repaint cycle and the browser knows how to make this as efficient as possible (e.g. when the current browser tab is deselected, it will wait until it is reselected before calling our function).

Basically, though other approaches are possible, requestAnimationFrame is the correct way to do animation in the browser.

To implement this kind of animation, we need a function that completes one frame of our scene. The function should update any dynamic data in the scene (e.g. the angle in the example below) and should also render the scene to our canvas. It should also call requestAnimationFrame at the end of the function to tell the browser that we are ready to execute another frame.

Consider the situation where our function takes time to run. As soon as we are done, we ask the browser for another frame.

Add this code under your context definition in scripts.js.

// initialise the scene data
let angle = 0;

function frame() {

    // update scene data
    angle += 0.03;

    // clear the canvas
    ctx.clearRect(0, 0, 300, 100);

    // draw the scene
    ctx.save();
    ctx.beginPath();
    ctx.translate(150, 50);
    ctx.rotate(angle);
    ctx.rect(-15, -15, 30, 30);
    ctx.stroke();
    ctx.restore();

    // request another frame
    requestAnimationFrame(frame)
}
// start the process
requestAnimationFrame(frame)

This is our first simple example (essentially identical to last week). However, we will make a few tweaks to remove the many magic numbers in the code.

Here, by magic numbers, I mean fixed numbers which are not explained and just happened to produce a good result.

For example, we call clearRect and pass 300 and 100. These were the width and height of our example canvas in the previous exercise. We could now set these to 600 and 600 to match our new canvas dimensions. However, it’s better to actually read the dimensions of the canvas element. In this way, we can change the canvas dimensions and the code will still work.

Similarly, the translate operation is moving the square to the center of the old canvas by translating it halfway across and halfway down. These values should be calculated from the canvas dimensions.

Finally, the square itself is very small. We can add a size variable which we can use when we draw the square.

Remember, we want the central point of the rotation to be at the centre of the square. We translate the origin to the centre of the canvas and then we draw the square from a point above and to the left of the origin. These distances are set to the magic numbers -15 (half the square side length) and 30 (the full length of a side). To remove these, they can be calculated from our proposed variable size.

Here’s some slightly modified code.

// initialise the scene data
let angle = 0;
const size = 300; // size is a constant (for now)

function frame() {

    // update scene data
    angle += 0.03;

    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // draw the scene
    ctx.save();
    ctx.beginPath();
    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate(angle);
    ctx.rect(-size/2, -size/2, size, size);
    ctx.stroke();
    ctx.restore();

    // request another frame
    requestAnimationFrame(frame)
}
// start the process
requestAnimationFrame(frame)

Its worth spending some time to understand this change and the benefits it brings. We can now change our canvas dimensions (width and height in the <canvas> attributes) and the square will always be placed in the center. We can also now easily change the size of our square by just modifying one line of code.

This is better code. Though it essentially does exactly the same thing as before, we can more easily modify it. This makes it both more maintainable and more flexible.

One final tweak we will make is to extract the value 0.03 into a variable. This value is the amount of angle which is added per frame. We would like this to be something we can easily modify programmatically.

// initialise the scene data
let angle = 0;
const size = 300;           // size is a constant (for now)
const rotationSpeed = 0.03; // rotationSpeed also.

function frame() {

    // update scene data
    angle += rotationSpeed;

    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // draw the scene
    ctx.save();
    ctx.beginPath();
    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate(angle);
    ctx.rect(-size/2, -size/2, size, size);
    ctx.stroke();
    ctx.restore();

    // request another frame
    requestAnimationFrame(frame)
}
// start the process
requestAnimationFrame(frame)

Let’s stop and think a little. Our three variables are determining the behaviour of the scene.

These units are important. They help us to understand what the numbers in our code mean.

Thinking about frames

Update the code to rotate the square exactly once per second.

Can you work out how many frames you are getting per second?

Example 2: Frame rates

Take another copy of your template folder and give it the above title.

If you look more closely at the requestAnimationFrame documentation you will see that the callback we pass to requestAnimationFrame (i.e. our frame() function) is passed a single argument which is a TimeStamp indicating (basically) the number of milliseconds since the document was initialised.

So in each frame we will have access to this timestamp value. From this, if we store the timestamp of the previous frame, we can calculate the amount of elapsed time since the last frame was executed.

Consider this code.

You can make this as a separate example if you have a template folder as described above

let p; // previous timestamp

// our function now expects a timestamp argument, ts
function frame(ts) {
    const elapsed = ts - p || 0; // calculate time since previous frame
    p = ts                       // update p, ready for the next frame
    console.log(elapsed);
    requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

The above code calculates the number of milliseconds elapsed since the previous frame and logs it to the console.

The calculation is done by declaring a variable p to store the timestamp of the previous frame. We declare p outside the frame() function and it is initially undefined.

Within the frame() function we calculate the number of milliseconds between this frame and the previous frame and assign it to a variable we named elapsed. This is calculated by taking the timestamp argument (ts) and subtracting the value of p. However, we need to account for the first frame, when p is undefined. In this case we assign elapsed to 0. Once we have calculated elapsed, we can now set p to the value of ts ready for the next frame.

This line is doing the work. The ts - p is evaluated first and will either be a number (for all but the first frame) or undefined (if p is undefined).

const elapsed = ts - p || 0;

The || operator is a logical OR. So if ts - p is a value, it just returns ts - p. If ts - p is undefined, the expression evaluates to 0.

Check your console to see the results. The time between frames on my laptop is usually around 16.66ms or 0.0166 seconds per frame which equates to 60 frames per second (1/60 = 0.01666).

Some devices may have a higher refresh rate.

Display frames per second

We can update our code to display frames per second.

// Initialise the context
ctx.font = "1em monospace";
ctx.textBaseline = "bottom";
ctx.textAlign = "right";

let p; // previous timestamp

function frame(ts) {
    // calculate elapsed time
    const elapsed = ts - p || 0;
    p = ts
    // update variables
    const fps = 1 / (elapsed / 1000);
    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    //render the scene
    ctx.fillText(`${fps.toFixed(2)} fps`, canvas.width, canvas.height);
    requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

We have added a few lines of code at the top to set up the font and text alignment.

Then we added this line, to convert milliseconds of elapsed time into frames per second:

const fps = 1 / (elapsed / 1000);

This is one divided by the elapsed time (converted from milliseconds to seconds).

Then we clear the canvas and call fillText() with a string.

ctx.fillText(`${fps.toFixed(2)} fps`, canvas.width, canvas.height);

the toFixed() method converts a number to a string with a fixed number of decimal places. We have used this inside a string template literal to add the "fps" units.

Notice that, since we moved the text alignment to ‘bottom’ and ‘right’, we can simply use the bottom right corner of the canvas as the coordinates for our call to fillText().

Plot a simple chart

From here its pretty easy to create a path based on our collected fps data in each frame.

To do this we need to manually create a path using the Path2D object. This is essentially the same as creating a path using the canvas context methods but it is more conveniently reusable.

Add the following after declaring p.

let x = 0;
let path = new Path2D();

The x variable will track our x-coordinate which will increment each frame. The path variable will be used to store a path, independent of the context object.

Now, after calculating fps, add this.

x += 1;
const y = canvas.height - (canvas.height / 120) * fps; 

Here we are calculating the x and y coordinates for our plot. We have introduces a magic number 120 which is the maximum of the y-axis (the fps value required to reach the top of the canvas).

Finally, after the call to fillText(), add the following to update and render the path.

path.lineTo(x, y);
ctx.stroke(path);

Here we are calling Path2D.lineTo() (Path2D objects have all the normal path methods of the CanvasRenderingContext2D interface) to add a line onto the path. Then we are passing our path object as an argument to the CanvasRenderingContext2D.stroke() method. This will draw the path to our canvas.

Notice this avoids any confusion with the fillText() operation because we are keeping the path data separate.

You should now see a line representing the live frame rate of the animation.

Add the following in after incrementing x to have the path (and x) reset when the line reaches the full width of the canvas.

if(x > canvas.width) {
    x = 0;
    path = new Path2D();
}

Now, because we increment x by 1 each frame, each pixel in the width of the canvas is equivalent to one frame.

To make the x axis reference time rather than frames, modify the code to increment x by elapsed (for one pixel per millisecond) or elapsed * 0.1 (for one pixel per 10ms), or elapsed * 0.01 (for one pixel per 100ms).

x += elapsed * 0.1;

You may also want to set the lineWidth to e.g. 0.5 and I set my strokeStyle to "red". Here’s what the final result should look like (on a slightly different canvas).

The final code is like this.

const ctx = canvas.getContext("2d");
let p;
let x = 0;
let path = new Path2D();
ctx.font = "1em monospace";
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.lineWidth = 0.5;
ctx.strokeStyle = "red";
function frameFPS(ts) {
    const elapsed = ts - p || 0;
    p = ts
    const fps = 1 / (elapsed / 1000);
    x += elapsed * 0.1;
    if(x > canvas.width) {
        x = 0;
        path = new Path2D();
    }
    const y = canvas.height - (canvas.height / 120) * fps; 
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillText(`${fps.toFixed(2)} fps`, canvas.width, canvas.height);
    path.lineTo(x, y);
    ctx.stroke(path);
    requestAnimationFrame(frameFPS)
}
requestAnimationFrame(frameFPS)

Another plot

See if you can develop a similar plot of a sine wave using Math.sin and passing the timestamp in seconds (try multiplying by a scaling factor).

Giving an object speed and acceleration

If we want to animate a moving object, we obviously need x and y coordinates, but we also might want to specify the speed of travel in, for example, the x direction (left and right). This is a bit like the rotationSpeed variable in the first example.

Ideally, we want to specify speed in units of pixels per second. So, we really do need to know how many seconds have passed. For example, if we give our moving object a speed of 100 pixels per second, then how far should we increment its position within one frame? If we know the elapsed time since the last frame, then we can simply multiply this with the object speed.

We will test this with a basic example. Start a new example (copy your template project) and name it speed and acceleration.

Here’s a fairly comprehensive example of implementing speed. We have introduced a few new ideas.

Our code will get a lot longer from now on, and we will begin to add more structure. You should study the code and implement it in each step. This is not all about the result, its about good code structure.

let p;  // previous timestamp

// initialise some variables
let x = canvas.width / 2;
let y = canvas.height / 2;
let xSpeed = 0;
let ySpeed = 0;

// initialise the canvas context properties
ctx.fillStyle = `yellow`;
ctx.lineWidth = 2;

// Define a path we can reuse to draw our object
let path = new Path2D();
path.arc(0, 0, 50, Math.PI * 0.15, Math.PI * 1.85);
path.lineTo(0, 0);
path.closePath();

function frame(ts) {
    // calculate elapsed time
    const elapsed = (ts - p) / 1000 || 0;
    p = ts

    // update position based on speed
    x += xSpeed * elapsed;
    y += ySpeed * elapsed;

    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //render the scene
    ctx.save();
    ctx.translate(x, y);
    ctx.fill(path);
    ctx.stroke(path);
    ctx.restore();

    requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

We have defined some variables. The values x and y dictate the position of our object. The values xSpeed and ySpeed specify the number of pixels we want to move our object each second.

We have also created a Path2D object with a fixed path. Our path is set up to always draw at the origin, but we can easily move it by translating the location of the origin. This is how we will move our object.

Inside our function, we calculate the elapsed time. We multiply this by the xSpeed and ySpeed variables when we increment the position. This is critical. It means that, no matter how long it has been since the last frame, we always move the correct distance.

If the frame rate drops drastically, the movement won’t slow down.

Once we have cleared the canvas, we place our object in the correct position by translating the origin (between save() and restore() calls to reset the origin each frame). Then we just pass our path object to fill() and stroke() to draw our object.

The code draws a shape to the canvas every frame. Our position isn’t changing because we have set the speed variables to zero.

Open the JavaScript console and use it to set the values of the x and y variables.

x = 400;
y = 100;

You can see that it is easy to move our object.

Similarly, try setting the xSpeed and ySpeed variables (negative values are fine too, just keep the numbers smallish).

xSpeed = -30;
ySpeed = 10;

Refresh the page any time you want to recentre the object.

Keyboard control

Implement a simple user interaction with a keydown event handler that sets the object xSpeed and ySpeed.

for example:

window.addEventListener('keydown', ev => {
    switch(ev.key) {
        case "ArrowLeft":
            xSpeed = -50;
            break;
        case "ArrowRight":
            xSpeed = 50;
            break;
        case "ArrowUp":
            ySpeed = -50;
            break;
        case "ArrowDown":
            ySpeed = 50;
            break;                
    }
});

Try it, you should have some keyboard control.

But we want to also listen for keyup events, so our object can stop moving.

window.addEventListener('keyup', ev => {
    switch(ev.key) {
        case "ArrowLeft":
            xSpeed = 0;
            break;
        case "ArrowRight":
            xSpeed = 0;
            break;
        case "ArrowUp":
            ySpeed = 0;
            break;
        case "ArrowDown":
            ySpeed = 0;
            break;                
    }
});

A simplification

That’s a lot of code we just added with the event handlers.

Letting go of ome key whilst holding others down can produce unexpected results.

A simpler approach to this, is to record which keys are pressed and respond accordingly within the frame() function.

Add the following code before the frame() function.

const keys = {
    ArrowUp: false,
    ArrowDown: false,
    ArrowLeft: false,
    ArrowRight: false,
}

Our keys variable will store information about which keys are currently pressed.

Now, we can replace our complex event listener code with this, much simpler code.

window.addEventListener('keydown', ev => {
    keys[ev.key] = true;
});

window.addEventListener('keyup', ev => {
    keys[ev.key] = false;
});

So, we can see that when a key is pressed, it will set the given property to true and when it is released, it will set it back to false.

The final addition we need is to read these value inside the frame() function to control the values of xSpeed and ySpeed.

xSpeed = (keys.ArrowRight - keys.ArrowLeft) * 50;
ySpeed = (keys.ArrowDown - keys.ArrowUp) * 50;

This needs to be added before we update x and y inside the frame() function.

Now we get the same effect with much less code.

We can also factor out this magic number 50 into a variable just called speed. Add this above the frame() function.

const speed = 50;

Now we can use it inside the function.

xSpeed = (keys.ArrowRight - keys.ArrowLeft) * speed;
ySpeed = (keys.ArrowDown - keys.ArrowUp) * speed;

Obviously, you should try changing the value of speed to see the impact.

Acceleration

Implementing acceleration rather than fixed speed is now fairly trivial. We need to define a variable acceleration at the top of the file. We also need to declare the speed variable with let rather than const as it need to change.

let speed;
const acceleration = 100; //pixels per second per second

We will use speed here to store the calculated change in speed which should be applied each frame.

These units should be pixels per second per second. So we are saying that, if we are accelerating in a particular direction for a whole second, we would increase our speed by 100 pixels per second

Now a simple update to our function will integrate this value into the calculations.

Now we are setting the speed variable based on how much time has passed.

speed = acceleration  * elapsed;
xSpeed += (keys.ArrowRight - keys.ArrowLeft) * speed;
ySpeed += (keys.ArrowDown - keys.ArrowUp) * speed;

This kind of movement works well enough, but it is very crude. For example, our object accelerates faster when moving diagonally. A more accurate model of movement and acceleration requires a tiny bit of scary maths (simple trigonometry). We will see this in the next exercise.

For now, we want to start refactoring this system.

Creating some order from the chaos

Our code has three clear sections. Initialisation, updating and drawing.

Prior to our frame() function we are initialising the context and the variables we need for our scene. We will leave this for now and focus on the frame() function itself.

Within the frame function, we have an update phase where we calculate the values of our variables and a draw phase, where we render the scene on the canvas.

function frame(ts) {    
    // calculate elapsed time
    const elapsed = (ts - p) / 1000 || 0;
    p = ts

    //------UPDATE---------

    // calculate the speed
    speed = acceleration  * elapsed;
    xSpeed += (keys.ArrowRight - keys.ArrowLeft) * speed;
    ySpeed += (keys.ArrowDown - keys.ArrowUp) * speed;

    // update position based on speed
    x += xSpeed * elapsed;
    y += ySpeed * elapsed;

    //------DRAW---------

    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //render the scene
    ctx.save();
    ctx.translate(x, y);
    ctx.fill(path);
    ctx.stroke(path);
    ctx.restore();

    requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

Since the rendering of the scene must happen after we have updated our variables, we can enforce this by defining two functions, update() and draw().

function update(elapsed) {
    // calculate the speed
    speed = acceleration  * elapsed;
    xSpeed += (keys.ArrowRight - keys.ArrowLeft) * speed;
    ySpeed += (keys.ArrowDown - keys.ArrowUp) * speed;

    // update position based on speed
    x += xSpeed * elapsed;
    y += ySpeed * elapsed;
}

function draw(ctx) {
    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    //render the scene
    ctx.save();
    ctx.translate(x, y);
    ctx.fill(path);
    ctx.stroke(path);
    ctx.restore();

}

function frame(ts) {
    // calculate elapsed time
    const elapsed = (ts - p) / 1000 || 0;
    p = ts
    update(elapsed);
    draw(ctx);
    requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

We have organised our code into a pattern that means our frame() function is now basically finished. We can completely change our object, its behaviour and how it looks, without touching the frame() function.

This is a good thing, we want to write code that just works.

So, can we design a more complex scene?

Multiple objects, moving independently

We are going to increase the complexity. This means we need to add more structure to our code. Otherwise it will get out of hand. The main structure we will add in this example is we will create an array of objects which we will use as the data for our scene.

Start a new example folder and update the title and heading to Example 3: Many objects. We can begin with our new pattern.

const ctx = canvas.getContext("2d");
let p;

function update(elapsed) {   
}

function draw() {
}

function frame(ts) {
    const elapsed = ts - p || 0;
    p = ts;
    update(elapsed / 1000);
    draw();
    requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Obviously, this doesn’t do anything yet. But it is repeatedly calling our empty update() and draw() functions, once per frame. We are passing the elapsed time in seconds into the update() function so it will have access to this as an argument.

This time, we are going to try to implement a much more complex scene. We want multiple things on the canvas, each doing something.

Let’s define some data for our scene. We will start with each of our things just having x and y coordinates. Each thing needs to be located randomly on the canvas.

Here’s a simple function we can use to create the data for a randomly placed thing.

function randomThing() {
    return {
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
    }
}

Now we can use the powerful Array.from method to create an array of ten random objects.

const things = Array.from({length: 10}, randomThing);

This is a neat way to create arrays of a specified length based on a callback function.

Open the developer tools console and check the value of things. It should be an Array containing ten objects, each with randomised x and y properties.

Now, to render these objects to the canvas, we will need to update the draw() function.

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    things.forEach(thing => {
        ctx.save();
        ctx.translate(thing.x, thing.y);
        ctx.fillRect(-10, -10, 20, 20);
        ctx.restore();
    });    
}

We are looping over our array of objects and calling translate() and fillRect() to draw 20px squares centred on the x and y coordinates of each one. We do this between save() and restore() because we are translating the origin. We must return the origin back to the top-left corner each time, otherwise, the origin will be moved further down and to the right each time.

You may want to set ctx.globalAlpha to a value less than 1 to reduce the opacity so we can see the squares overlapping. You should do this outside of the functions, probably at the top of the code after initialising the ctx variable.

Refresh the page a few times and you will see the squares spawn at different locations.

Size

Making the size variable is an easy tweak. Each object needs a new size property.

function randomThing() {
    return {
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        size: 10 + Math.random() * 190
    }
}

The above code gives each object a size of between 10 and 200. Now we need to implement this in the drawing code.

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    things.forEach(thing => {
        ctx.save();
        ctx.translate(thing.x, thing.y);
        ctx.fillRect(-thing.size/2, -thing.size/2, thing.size, thing.size);
        ctx.restore();
    });    
}

No more magic numbers (except zero, which is a magic number. and 2 but it should be clear what the purpose of these is).

Again, refresh the page a few times and you should see the squares are each a different size.

Rotation

We want our objects to rotate. This is going to require code in the update() function.

First, we will give them all an angle property and initialise it to a random value (maximum 2 * Math.PI).

function randomThing() {
    return {
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        size: 10 + Math.random() * 190,
        angle: Math.random() * 2 * Math.PI
    }
}

Notice, we added a new property into the objects inside our array. This gives us new data to use when we update and draw our scene.

To make use of this, we can add this simple code into the draw() method.

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    things.forEach(thing => {
        ctx.save();
        ctx.translate(thing.x, thing.y);
        ctx.rotate(thing.angle);
        ctx.fillRect(-thing.size/2, -thing.size/2, thing.size, thing.size);
        ctx.restore();
    });    
}

We added a call to rotate() between the save() and restore() code, after translate() and before fillRect().

Refresh the page, you should see the squares are all rotated by a different amount.

Now, we can finally add something into the update() function to change the angle property.

function update(elapsed) {
    things.forEach(thing => {
        thing.angle += 2 * Math.PI * elapsed;
    });
}

This makes all the squares rotate at the same rate, 2 * Math.PI radians per second or one complete turn per second.

We can easily give each object its own rate of rotation in the usual way. However, we need to think about the range of values we want to allow. Negative rotation rates will rotation anti-clockwise.

If we limit the maximum rotation rate to one complete turn per second (2 * Math.PI radians per second), then our rate should be between -2 * Math.PI and 2 * Math.PI radians per second.

So we can start with the minimum (-2 * Math.PI) and add a random number multiplied by the full range of 4 * Math.PI.

function randomThing() {
    return {
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        size: 5 + Math.random() * 95,
        angle: Math.random() * 2 * Math.PI,
        rotationRate: -2 * Math.PI + Math.random() * 4 * Math.PI
    }
}

Feel free to increase the range to your own liking. But make sure you understand this first.

To integrate this individualised rotation rate, we need a tweak to the update() function.

function update(elapsed) {
    things.forEach(thing => {
        thing.angle += thing.rotationRate * elapsed;
    });
}

Now refreshing the page a few times should show that each square is rotating at a different rate and that the rates are applied randomly.

This is a good time to experiment with more objects, try increasing the number of objects to 100. Then try 1000 objects.

There is a danger you will create too many objects. You might be interested to try to find out what the limit is. For higher numbers of objects, you need to drop the globalAlpha value down lower.

Movement

Let’s finish the example by having the squares sweep across the canvas horizontally. The idea here is that each square will have an xSpeed property (pixels per second). This will be used to update the x coordinate each frame.

But in addition, if the square disappears off one side of the canvas, it should reappear on the other side. Of course, some will be moving faster than others.

We can begin by giving the randomised objects an xSpeed property.

function randomThing() {
    return {
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height,
        size: 5 + Math.random() * 95,
        angle: Math.random() * 2 * Math.PI,
        rotationRate: -2 * Math.PI + Math.random() * 4 * Math.PI,
        xSpeed: -canvas.width + Math.random() * 2 * canvas.width
    }
}

The value is set to between -canvas.width and canvas.width pixels per second. So each of our squares will take at least one second to cover the full width of the canvas.

To make them move, we need to update the x coordinate each frame.

function update(elapsed) {
    things.forEach(thing => {
        thing.angle += thing.rotationRate * elapsed;
        thing.x += thing.xSpeed * elapsed;
    });
}

Refresh the page a few times. You should see some squares quickly disappear off the edges of the canvas. Others take more time.

We want the squares to wrap around the canvas. But they should be fully gone before they appear back at the other side.

First, we will check for the x property being over canvas.width, to catch squares which have passed the right edge. If they are passed the edge, we will set their x property to zero, which will bring them back on the left edge.

function update(elapsed) {
    things.forEach(thing => {
        thing.angle += thing.rotationRate * elapsed;
        thing.x += thing.xSpeed * elapsed;
        if(thing.x > canvas.width) {
            thing.x = 0;
        }
    });
}

Now we need to implement the same in the other direction with an else clause.

function update(elapsed) {
    things.forEach(thing => {
        thing.angle += thing.rotationRate * elapsed;
        thing.x += thing.xSpeed * elapsed;
        if(thing.x > canvas.width) {
            thing.x = 0;
        } else if(thing.x < 0) {
            thing.x = canvas.width;
        }
    });
}

This works (try refreshing a few times) but the squares are still half visible when they disappear and they appear half visible on the other side. Watch a slower square. You will see the transition between edges is janky.

It would be ideal if we could draw another rectangle on the other side when they cross the edge.

Warning, this is getting a bit advanced. Don’t worry if you find this difficult to follow. We are deep into the details now. The cost of this step is relatively high (in terms of the complexity it introduces to our code) and the advantages are relatively low (it will make our scene only marginally better). It would be perfectly reasonable to end here or to fudge it in some simpler way.

We can calculate the distance from the center of our square to a corner as the square root of the sum of the squares of each side of a quarter of the maximum square. For example the distance from the center to the corner of a 100 pixel square is ((100/2)2 + (100/2)2)0.5 = 71.

We can add this calculation into our draw() function to calculate the threshold beyond which we need to draw two copies of the square (one at each edge).

In the modified draw() function, we are checking for whether a square is near enough to one or other edge. If it is, we need to translate the canvas origin in the correct direction, rotate around the new origin and draw a second rectangle.

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    things.forEach(thing => {
        ctx.save();
        ctx.translate(thing.x, thing.y);

        // drawing the original square left the canvas rotated
        // so we have wrapped this in save/restore pair
        // otherwise the translate moves below would be at an angle
        ctx.save();
        ctx.rotate(thing.angle);
        ctx.fillRect(-thing.size/2, -thing.size/2, thing.size, thing.size);
        ctx.restore();

        // calculate the 'buffer' zone, where we need to draw two squares
        const buffer = (2 * (thing.size/2)**2)**0.5;

        // If we are at either edge, draw a second square
        // translating horizontally in the appropriate direction
        // (hence the canvas must be unrotated)
        // Then we can rotate (around the center of the square)
        // and draw
        if(thing.x + buffer > canvas.width) {
            ctx.translate(-canvas.width, 0);
            ctx.rotate(thing.angle);
            ctx.fillRect(-thing.size/2, -thing.size/2, thing.size, thing.size);    
        } else if(thing.x - buffer < 0) {
            ctx.translate(canvas.width, 0);
            ctx.rotate(thing.angle);
            ctx.fillRect(-thing.size/2, -thing.size/2, thing.size, thing.size);
        }
        ctx.restore();
    });    
}

Notice that we also needed to wrap the original rotate() and fillRect() calls with save() and restore() in order to ensure the canvas was back in the original unrotated state. If we had not done this, the later translate() call would translate the origin in the wrong direction.

Try it again, refresh a few times to see it working. If you watch a slower square, you should see it will appear on both sides at the same time when it crosses an edge and the transition appears much smoother.

Animate the colour

See if you can add a new feature such as giving each square a different colour.

Try to use hsl() colours with template literals and animate the hue, saturation or lightness.

If you’re not sure, check this tutorial.

Conclusion

This exercise has introduced some patterns for creating moderately complex animated canvases. We have created a function that completes a single frame of the animation. We have then initialised variables which are used as key data to render the scene. We have made these variables dynamic by updating them frame-by-frame and separating this process from drawing the scene. We have looked at how to organise code around this pattern by splitting off an update() and a draw() function. This allows us to focus either on the data and how it updates or on the graphical aspects and how they are rendered.

In the next exercise, we will move this pattern to an object-oriented model.