How to make your canvas scale to any screen size with p5.js
While also preserving aspect ratio. (Otherwise known as letterbox scaling)
I’ve been recently exploring the p5.js library. It offers an intuitive API for using the HTML canvas element. It’s mainly used for creative coding but can also be used to make games.
If you’re familiar with my youtube channel, you know that I’ve been using the Kaboom.js library for making games. While it’s a great library, I find that it’s often frustrating when it comes to manipulating the canvas itself.
For some projects, I would like to use the DOM for making the UI. However, I need total control of the positioning and sizing of the canvas which Kaboom doesn’t let me have. That’s why I went looking for other options.
I also wanted to expand my horizons.
I’m writing this specific post to document how I achieved letterbox scaling (scaling the canvas regardless of screen size without losing the aspect ratio) in p5.
Setting up p5.js
I think it’s interesting to take some time to cover how to setup a p5.js project. While you could learn how to do this elsewhere, I’m assuming that a portion of the audience reading this might have never used p5.
My goal is to go over how to set the library up to work with JavaScript modules so that you can use the import keyword while working on your project. With a setup like this, you won’t need to use npm and a bundler like Vite which means less complexity to deal with.
The setup shown in this article will allow you to avoid the messiness that comes with using a growing list of script tags to import your various js files as the project grows.
(Step 1) Download the library and add it to your project via a script tag
Let’s assume the following content of an index.html file which is the entry point of your game.
<html>
<body>
<script src="p5.js"></script>
</body>
</html>
Let’s assume the following directory structure
myGame
|- index.html
|- p5.js
The p5 library can be downloaded as .js or .min.js file from the official website here. Unfortunately, they don’t provide a .mjs file. If they did, you could have imported p5 to your project like so.
import p5 from "p5.mjs";
and it would be the end of the story. Since it’s not the case we need to do a workaround.
(Step 2) Import your main.js file as a module
Let’s assume that a file named main.js is going to be the entry point from which you’ll write your game code.
Here is the updated folder structure to reflect this assumption.
myGame
|- index.html
|- p5.js
|- main.js
Now update the content like so.
<html>
<body>
<script src="p5.js"></script>
<script type="module" src="main.js"></script>
</body>
</html>
Now within your main.js you’ll be able to use the import keyword to import functions and variables from other js files. This is possible because we added the type=”module” attribute to the script tag importing main.js in your project.
If you try to use p5 in main.js like shown in the p5 docs, it won’t work. Let’s assume the following content of main.js
function setup() {
console.log("checking if p5 runs")
}
function draw() {
}
p5 will normally look for a setup and draw function and execute them. However, if you try running the code above by opening the index.html file in a browser, you’ll notice that nothing gets printed out to the console.
That’s because our main.js file was imported in our html by specifiying it as a module. That means that what we write in main.js is not accessible globally. That’s why p5 can’t find the setup and draw functions we’ve defined.
To summarize, main.js has access to p5 functions and methods because its script tag is right after the one for p5.js in index.html. However, p5 doesn’t have access to what we write in main.js since it’s of the type module and therefore the code written in main.js is not globally accessible.
In conlusion, for p5 to work within our main.js file we need to write our starter code differently. Clear the content of main.js and write the following code.
new p5((p5) => {
p5.setup = () => {
console.log("checking if p5 runs")
}
p5.draw = () => {
}
})
p5 allows you to use the p5 constructor to create an instance of p5. This instance takes a function and passes to it the context which is the param named p5 (The name is arbitrary, you could have gone with “toaster” and it would work the same). We are then able to define the setup and draw functions needed by p5.
Running the code above, you should notice that “checking if p5 runs” is indeed printed to the console. Your setup is now ready.
You might still not see the benefit of this setup. Consider the following example where you’re writing a game where you need a ball. You would write all the logic related to the ball in a Ball.js file.
export default class Ball {
draw(p5) {
//... drawing logic for the ball object
}
}
Now in main.js you can do :
import Ball from "./Ball.js" // adding .js is very important otherwise javascript will throw a MIME type error.
new p5((p5) => {
const ball = new Ball()
p5.setup = () => {
//... your setup logic
}
p5.draw = () => {
ball.draw(p5)
}
})
Being able to export code from other files like shown above is really handy when it comes to keeping your codebase organized.
Writing logic for resizing the canvas
Now has come the time to actually do what this article is all about. Let’s start with this base. (Considering you have a the same setup described previously)
new p5((p5) => {
p5.setup = () => {
}
p5.draw = () => {
}
})
Let’s add a couple of variables and constants.
new p5((p5) => {
const baseWidth = 160
const baseHeight = 144
const aspectRatio = baseWidth / baseHeight
let scaleFactor = 1
p5.setup = () => {
}
p5.draw = () => {
}
})
baseWidth and baseHeight are the width and height of your canvas before you start resizing it. It’s the base from which you determine how you’ll place objects on the screen.
Here, I’ve set the baseWidth and baseHeight to be of a 160x144 px resolution which is the resolution of the GameBoy. You’re free to choose other resolutions like 1280x720 px, 1920x1080 px, etc…
aspectRatio is where we store the aspect ratio of the base canvas. We want to maintain the same aspect ratio regardless of screen size.
Finally, the scaleFactor is a what you’ll use later to make sure that everything drawn in the canvas scales appriopriately as the canvas is resized. We initially set it to 1 but we will change it depending on the current canvas size. That’s why we’re setting this up as a variable rather than a constant.
new p5((p5) => {
const baseWidth = 160
const baseHeight = 144
const aspectRatio = baseWidth / baseHeight
let scaleFactor = 1
p5.setup = () => {
}
p5.draw = () => {
}
p5.windowResized = () => {
p5.setup()
}
})
Add the windowResized function to your code and call the setup function within it. windowResized is a function offered by p5 and runs every time the window is being resized. We want to call the setup function within it so that we also resize the canvas when the window is resized.
new p5((p5) => {
const baseWidth = 160
const baseHeight = 144
const aspectRatio = baseWidth / baseHeight
let scaleFactor = 1
p5.setup = () => {
}
p5.draw = () => {
}
p5.windowResized = () => {
p5.setup()
}
const updateCanvasDimensions = () => {
if (p5.windowWidth / p5.windowHeight > aspectRatio) {
return {
canvasWidth: p5.windowHeight * aspectRatio,
canvasHeight: p5.windowHeight
};
}
return {
canvasWidth: p5.windowWidth,
canvasHeight: p5.windowWidth / aspectRatio
};
}
})
Add the function updateCanvasDimensions to your code. This function is not part of p5. We decided to create it to make our code cleaner. Its role is to determine the width and height of your canvas according to the current dimensions of the browser window.
By default we want the width and height of our canvas to be the same as the width and height of the browser window unless the aspect ratio is not respected.
While the window is being resized its width or the height can become too wide or too tall therefore violating the aspect ratio. In those cases, we need to derive a different canvas width or height than the one of the browser window.
The first if statement checks if the window is too wide by doing the division between the window’s height and width. If the result of the division is bigger than the desired aspect ratio, a new canvas width is computed by doing the multiplication of the window’s height times the aspect ratio. The new canvas dimensions are then returned.
The same logic applies when the height is taller than desired. Only this time we get the new canvas height by dividing the window’s width by the aspect ratio. The new canvas dimensions are then returned.
This all possible because of the mathematical relationship between the width, the height and the aspect ratio.
Now that the resizing logic is done we need to actually use it in our setup function to resize our canvas. Add the following code to the setup function.
p5.setup = () => {
const {canvasWidth, canvasHeight} = updateCanvasDimensions()
const myCanvas = p5.createCanvas(canvasWidth, canvasHeight)
scaleFactor = baseWidth / canvasWidth
const x = (p5.windowWidth - canvasWidth) / 2
const y = (p5.windowHeight - canvasHeight) / 2
myCanvas.position(x, y)
p5.pixelDensity(window.devicePixelRatio)
p5.strokeWeight(2 * scaleFactor)
}
We call updateCanvasDimensions to get the new updated width and height for our canvas. We use them on the second line to create our canvas.
Next, the scaleFactor is computed. It allows us to know by how much we need to scale what we draw on the canvas when the canvas itself is being resized.
What comes next is code to center the canvas at all times. The way we center the canvas is by using the position method provided by p5.
To understand how positioning is done, let’s look at a couple examples.
x = 0, y = 0. The canvas will be positioned starting from the top left corner of the window. This is considered the origin and it looks like this:
x = p5.windowWidth - canvasWidth > 0, y = 0. This case occurs when the canvas has the same height as the window hence y = 0. We get the following positioning :
x = (p5.windowWidth - canvasWidth) / 2 > 0, y = 0. This case illustrates why we divided by two in the code. This is how we achieve the black bar effect for when canvas can’t fill the whole window without violating the desired aspect ratio.
The same logic applies on the y axis when the canvas height doesn’t fit with the window’s height.
Now for the last two lines of the setup function we simply set the pixel density of the canvas to the device pixel ratio. This is needed in case the canvas is being viewed on a high dpi or retina display. On these displays, a higher amount of pixels than needed is used to render elements on the screen giving a sharper look as a result. So the relationship between 1 screen pixel and one in-browser pixel (the actual term is css pixel) is not one to one on these displays.
The last line uses the strokeWeight method to set the thickness of the outline of the rectangle we’re going to draw next in the draw function. We’ve determined a thickness of 2px but multiplied by the scaleFactor so that when the canvas resizes the outline will scale accordingly.
To test that canvas scaling does work, add the following code to the draw function.
p5.draw = () => {
p5.background(255, 204, 0);
p5.rect(
50 * scaleFactor,
50 * scaleFactor,
16 * scaleFactor,
16 * scaleFactor
);
};
The first line sets the background of the canvas to a yellowish color using 255, 204 and 0 as the respective r, g and b values.
The second line uses p5’s rect method to draw a rectangle on the canvas. This rectangle has an x coordinate = 50, y = 50 a width = 16 and height = 16. Like mentioned previously, we need to multiply by the scaleFactor each of these values so that the rectangle scales in size and position as the canvas scales.
You should now have a fully scalable canvas!
Conclusion
Hope this article was useful and you learned a new thing or two.
If you’re interested in JavaScript game development, you’ll find full project tutorials on my YouTube channel where I show you how to build zelda-likes, pokemon-likes, platformers, etc… You can take a look here.
If you’re interested in more higher level JavaScript gamedev concept content like this article, feel free to subscribe to this newsletter to not miss out on future content!