In the recent years, the world of browser games development has been rapidly growing.
What started as simple programming using the HTML5 canvas object that exists in all browsers has become a huge library of extensions (known to us as js extensions) that allows us to develop games and graphical activities in a very simple and fast way.
In this tutorial, we will focus on a tool called Phaser (Phaser 3 to be precise) that enables quick and simple development of canvas graphics and games.
Basic requirements
- Very basic Javascript knowledge is needed to understand the code that will appear in this article.
- The library must be downloaded from here (or use the library files stored on the CDN).
What is Phaser?
Phaser is an HTML5 library for easy and efficient browser games development. The library was created specifically to harness the benefits of modern browsers, for both desktop / laptop computers and smartphones.
As far as the browser is concerned, the only requirement is support for the canvas tag.
The Game - Breakout
In this guide, we will be making a simple Breakout game with the following features:
- One single level with 45 rectangles, a ball and a paddle.
- The goal of the game is to destroy as many bricks as possible without the ball falling to the bottom of the screen.
- We will be using the mouse to move the paddle (movement on the horizontal axis only)
- The player's score will be displayed at the top right of the screen while each colored brick will credit the player with a different score. As follows:
- Blue: 10 points.
- Green: 20 points.
- Purple: 30 points.
- Yellow: 40 points.
- Red: 50 points.
You can play the final product of the game here:
Installing the environment
The environment should consist of the following files:
- An index.html file that will contain the logic of the game in addition to the HTML.
- An "assets" folder that contains all the images used in the game.
Add the following code to index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Phaser Breakout Tutorial</title>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.15.1/dist/phaser-arcade-physics.min.js"></script>
</head>
<body>
<script></script>
</body>
</html>
In addition, the script that imports the Phaser framework must be added (can also be added from the CDN as in the image above)
And now we can start working on the logic of the game.
Development
Let's start by opening a <script> tag inside the body. Inside the newly created script tag we will first handle the game configuration:
var config = {
type: Phaser.AUTO,
width: 800,
height: 640,
physics: {
default: 'arcade',
arcade: {
gravity: false,
//debug: true
},
},
scene: {
preload: preload,
create: create,
update: update
}
};
Here is a brief explanation of the various elements in the config object:
- type: defines the renderer which phaser should use:
- Phaser.AUTO
- Phaser.CANVAS
- Phaser.WEBGL
Usually AUTO is used and then the library alone decides whether and when to use CANVAS and when to use WEBGL as needed to make the best use of your computer or device resources.
- width: Sets the width of the game pane.
- height: Sets the height of the game pane.
- physics: Basic physical settings for the game. For example, in this game there is no need for a gravitational element.
- scene: The list of "states" we have in the game and references to their appropriate functions.
Note that the "debug: true" line can be enabled in the development phase of the game (especially when working on a game like Breakout which mainly consists of collisions). When enabled, this option draws the collision rectangles of every sprite in the game.
We will now define some global variables for our game:
var game = new Phaser.Game(config);
var gameStarted = false;
var score = 0;
var scoreText;
var gameWon = false;
- game: Our game object that receives the configuration object that we defined in the previous section.
- gameStarted: A boolean variable that alerts when the game has started.
- score: The player's score.
- scoreText: A variable that will contain the score text object.
- gameWon: A boolean variable that alerts when the game has ended and the player wins.
We will now move on to the main functions of the Phaser framework - create, preload and update.
Let's begin with the preload function:
function preload() {
this.load.image('ball', './assets/ball.png');
this.load.image('paddle', './assets/paddle.png');
this.load.image('brick1', './assets/brick1.png');
this.load.image('brick2', './assets/brick2.png');
this.load.image('brick3', './assets/brick3.png');
this.load.image('brick4', './assets/brick4.png');
this.load.image('brick5', './assets/brick5.png');
}
Our preload function basically loads all the components (in our case, only images) needed for the game to work.
In terms of hierarchy, this function is the first one called by Phaser even before the game launches.
The assets for the game can be downloaded from here.
The create function:
This function is responsible for initializing all the objects we have in the game (paddle, ball and bricks).
function create() {
this.player = this.physics.add.sprite(0, 610, 'paddle').setScale(0.2);
this.ball = this.physics.add.sprite(0, 575, 'ball').setScale(0.2);
}
Let's start with the player and the ball. We will use the add.sprite function from the physics object to define the location (x and y), as well as the key for the image we defined in the preload function.
This form of loading allows us to actually use the same image in several places in the code without redefining the path each time (just remember the key we defined in the preload function - for example: 'paddle', 'ball').
Optional - In addition, we will reduce the size of the images using setScale (0.2) because they are quite large.
Once we've created the player and the ball, it's time to move to the bricks. Let's add the following code to the create function:
this.blueBricks = createBricksGroup('brick1', 170, this);
this.greenBricks = createBricksGroup('brick2', 140, this);
this.purpleBricks = createBricksGroup('brick3', 110, this);
this.yellowBricks = createBricksGroup('brick4', 80, this);
this.redBricks = createBricksGroup('brick5', 50, this);
We divide the bricks by colors into groups. Basically, each color will have its own "group" that will contain all the bricks associated with it.
For each group, we will use the createBricksGroup function which will be explained next.
createBricksGroup function:
function createBricksGroup(name, y, scene) {
return scene.physics.add.group({
key: name,
repeat: 8,
immovable: true,
setXY: {
x: 80,
y: y,
stepX: 80
},
setScale: { x: 0.2, y: 0.2 }
});
}
This function is responsible for creating a new set of bricks at a given position. These are the function's parameters:
- name: The name of the component we want to load (as defined by the preload function).
- y: The vertical position of the group (this is the only component that varies between each group).
- scene: The Phaser scene object to which we associate the group.
A new "group" object will be created and returned by the function.
Here we are using the add.group function to add a new set of bricks. These are the properties for the group:
- key: The key of the same element that we defined in the preload function.
- repeat: The number of times we want to copy this image again into the group in addition to the original image (here we want 9 bricks in a row so we will write 8).
- immovable: This property tells Phaser that the object cannot be moved after any sort of force is applied to it (during collision).
- The setXY object:
- x: The position of the image on the horizontal axis.
- y: The position of the image on the vertical axis.
- stepX: Here we basically set the horizontal distance between all the copied images (which we defined in "repeat") so that they do not overlap over one another.
- setScale: Here we scale down the image just like we did for the ball and the paddle.
We will now move on to collisions - that is, when the ball hits the player, the bricks or the screen boundaries.
Let's add the following code to the create function:
this.ball.setCollideWorldBounds(true);
this.ball.setBounce(1, 1);
this.physics.world.checkCollision.down = false;
this.physics.add.collider(this.ball, this.blueBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.greenBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.purpleBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.yellowBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.redBricks, brickCollision, null, this);
this.player.setImmovable(true);
this.physics.add.collider(this.ball, this.player, playerCollision, null, this);
In the first few lines, we basically define that the ball will collide with the limits of our world (which is actually the width and height we have set for the game object). We also remove the collision check for the bottom of the screen so that the player can lose the game if the ball crosses it.
The setBounce function specifies that the ball will always maintain speed in the horizontal and vertical axis and thus it will jump back when it collides with the screen boundaries.
We then add calls to the add.collider function (for each of the rectangular groups), a built-in Phaser function, which does an automatic collision check between 2 objects.
The first parameter for the function is the object that collides, the second parameter is the object that gets collided with and the third parameter is the callback function that will be triggered when the collision happens.
Finally, we add one last function call that is responsible for checking the ball-paddle collision and we define that the paddle will not move when the ball hits it (in the same way we defined for the bricks with the "immovable" parameter).
We will now move on to the ball-player collision and the ball-brick collision.
The playerCollision function will handle the collision between the ball and the player:
function playerCollision(ball, player) {
var velX = Math.abs(ball.body.velocity.x);
if (ball.x < player.x) {
ball.setVelocityX(-velX);
} else {
ball.setVelocityX(velX);
}
}
When the collision occurs, the function checks if the ball is on the left or the right side of the paddle - we then know which direction the ball should fly to by changing the polarity of the velocity.
The brickCollision function will handle the collision between the ball and a certain brick:
function brickCollision(ball, brick) {
brick.destroy();
addScore(brick);
if (ball.body.velocity.x === 0) {
if (ball.x < brick.x) {
ball.body.setVelocityX(-130);
} else {
ball.body.setVelocityX(130);
}
}
if (isWon(this.blueBricks, this.greenBricks, this.purpleBricks, this.yellowBricks, this.redBricks)) {
this.wonText.setVisible(true);
this.ball.destroy();
this.player.destroy();
gameWon = true;
}
}
Here is a breakdown of the function:
- First of all, we need to remove the brick that the ball hit using the destroy() function.
- We want each brick to deliver a different score. Here we call the addScore function to add the player's score for the same brick that the ball destroyed.
- The main evaluation of this function is responsible for checking which direction should the ball fly to after it stopped moving following the collision with the brick (again - as with the player, we check which side of the brick was hit by the ball and that way we know the direction we want).
- We finally check using the isWon function, whether this is the last brick the player has destroyed - If so, the winning logic of the game can be executed.
If this is the last brick, we will present a victory text to the player and remove the ball and paddle from the screen.
Now let's expand on the functions we've seen in the brickCollision function - the addScore and the isWon functions.
The addScore function:
function addScore(brick) {
switch (brick.texture.key) {
case "brick1":
score += 10;
break;
case "brick2":
score += 20;
break;
case "brick3":
score += 30;
break;
case "brick4":
score += 40;
break;
case "brick5":
score += 50;
break;
}
scoreText.setText('Score: ' + score);
}
The purpose of this function is to evaluate which brick (by the key) did the player hit and update the score accordingly.
We basically check using a switch case what is the key of the brick the player hit (to distinguish the color) so that we can determine the appropriate score for each colored brick.
Finally, we update the text object of the score so it updates accordingly on the screen.
The isWon function:
function isWon(blueBricks, greenBricks, purpleBricks, yellowBricks, redBricks) {
return (
blueBricks.countActive() === 0 &&
greenBricks.countActive() === 0 &&
purpleBricks.countActive() === 0 &&
yellowBricks.countActive() === 0 &&
redBricks.countActive() === 0
);
}
This function checks if there are any bricks left on the screen by comparing the length of each group of bricks to 0.
If there are no bricks left, the player won.
We will now continue with Phaser's create function. Let's add the following code, which creates a new text object that will be displayed exactly at the center of the screen to alert the player that in order to start playing - they have to click on the screen:
this.startText = this.add.text(
this.physics.world.bounds.width / 2,
this.physics.world.bounds.height / 2,
'Click to Play',
{
fontFamily: 'Arial',
fontSize: '50px',
fill: '#fff'
}
);
this.startText.setOrigin(0.5);
Just like the start text, let's add a text object for when the player looses (game over). This text will also be displayed exactly at the center of the screen and will not be shown at the start of the game (using the function - setVisible).
this.gameOverText = this.add.text(
this.physics.world.bounds.width / 2,
this.physics.world.bounds.height / 2,
'Game Over!',
{
fontFamily: 'Arial',
fontSize: '50px',
fill: '#fff'
}
);
this.gameOverText.setOrigin(0.5);
this.gameOverText.setVisible(false);
And in the same way, we will also add the end game text object (win):
this.winText = this.add.text(
this.physics.world.bounds.width / 2,
this.physics.world.bounds.height / 2,
'You Win!',
{
fontFamily: 'Arial',
fontSize: '50px',
fill: '#fff'
}
);
this.winText.setOrigin(0.5);
this.winText.setVisible(false);
Finally, let's create the score text that will be displayed at the top right corner of the game:
scoreText = this.add.text(
10,
10,
'Score: 0',
{
fontFamily: 'Arial',
fontSize: '18px',
fill: '#fff'
}
);
We will now move on to the update function, which will contain the game logic:
function update() {
if (!gameWon) {
if (isGameOver(this.physics.world, this.ball)) {
this.gameOverText.setVisible(true);
this.ball.destroy()
this.player.destroy();
} else {
this.player.setX(this.input.x);
if (!gameStarted) {
this.ball.setX(this.player.x);
if (this.input.activePointer.isDown) {
gameStarted = true;
this.ball.setVelocityY(-300);
this.startText.setVisible(false);
}
}
}
}
}
We first check wether the player has already won the game using a boolean variable - if he did, there is no point continuing updating the logic of the game.
Then, we check wether the player lost the game using the isGameOver function (which will be explained next).
If the player lost, we display the text accordingly on the screen using the object we prepared in the create function.
If not, we can start with the logic of the game:
- In order for the paddle to always follow the player's cursor, we define that its horizontal component is always equal to the horizontal component of the cursor.
- In order for the ball to always be adjacent to the paddle before we start playing, we define that its horizontal component is always equal to the horizontal component of the paddle.
- We check if the player clicked on the screen.
If so - we move the ball at a vertical speed of -300 (upwards) and hide the starting text.
Finally, here's the isGameOver function:
function isGameOver(world, ball) {
return ball.body ? ball.body.y > world.bounds.height : true;
}
This function simply checks wether the ball collided with the bottom of the screen to determine if the player lost the game.
We also check for the ball.body object in case we called the ball.destroy() function which then returns an undefined value for ball.body.
What to do next?
For those who are interested, the game can be improved as follows (try it yourself!):
Beginners
- Display the player's score below the text "You Win!" when the game is over.
- Increasing the speed of the ball every time it hits the paddle or a brick.
- When a brick is hit for the first time, it is not destroyed - only the second time - after image is changed to a "broken" brick. (Maybe even the number of times it breaks will depend on the color of the current brick).
- Add a background image to the game.
- Adding sound to the game (when the ball hits something, background music, etc.).
Advanced:
- Add levels to the game with bricks of different colors and shapes.
- Leaderboard/Highscore system of the most talented players.
- Add powerups that fall from a brick after it breaks (like shots, ball speed, increased paddle width).
- Changing the angle of movement of the ball depending on where the ball hit the paddle.
The full code of the game
The game including all the relevant folders and assets can be downloaded from here, or the code can be copied as follows:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Phaser Breakout Tutorial</title>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.15.1/dist/phaser-arcade-physics.min.js"></script>
</head>
<body>
<script>
var config = {
type: Phaser.AUTO,
width: 800,
height: 640,
physics: {
default: 'arcade',
arcade: {
gravity: false,
//debug: true
},
},
scene: {
preload: preload,
create: create,
update: update
}
};
var game = new Phaser.Game(config);
var gameStarted = false;
var score = 0;
var scoreText;
var gameWon = false;
function preload() {
this.load.image('ball', './assets/ball.png');
this.load.image('paddle', './assets/paddle.png');
this.load.image('brick1', './assets/brick1.png');
this.load.image('brick2', './assets/brick2.png');
this.load.image('brick3', './assets/brick3.png');
this.load.image('brick4', './assets/brick4.png');
this.load.image('brick5', './assets/brick5.png');
}
function create() {
this.player = this.physics.add.sprite(0, 610, 'paddle').setScale(0.2);
this.ball = this.physics.add.sprite(0, 575, 'ball').setScale(0.2);
this.blueBricks = createBricksGroup('brick1', 170, this);
this.greenBricks = createBricksGroup('brick2', 140, this);
this.purpleBricks = createBricksGroup('brick3', 110, this);
this.yellowBricks = createBricksGroup('brick4', 80, this);
this.redBricks = createBricksGroup('brick5', 50, this);
this.ball.setCollideWorldBounds(true);
this.ball.setBounce(1, 1);
this.physics.world.checkCollision.down = false;
this.physics.add.collider(this.ball, this.blueBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.greenBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.purpleBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.yellowBricks, brickCollision, null, this);
this.physics.add.collider(this.ball, this.redBricks, brickCollision, null, this);
this.player.setImmovable(true);
this.physics.add.collider(this.ball, this.player, playerCollision, null, this);
this.startText = this.add.text(
this.physics.world.bounds.width / 2,
this.physics.world.bounds.height / 2,
'Click to Play',
{
fontFamily: 'Arial',
fontSize: '50px',
fill: '#fff'
}
);
this.startText.setOrigin(0.5);
this.gameOverText = this.add.text(
this.physics.world.bounds.width / 2,
this.physics.world.bounds.height / 2,
'Game Over!',
{
fontFamily: 'Arial',
fontSize: '50px',
fill: '#fff'
}
);
this.gameOverText.setOrigin(0.5);
this.gameOverText.setVisible(false);
this.winText = this.add.text(
this.physics.world.bounds.width / 2,
this.physics.world.bounds.height / 2,
'You Win!',
{
fontFamily: 'Arial',
fontSize: '50px',
fill: '#fff'
}
);
this.winText.setOrigin(0.5);
this.winText.setVisible(false);
scoreText = this.add.text(
10,
10,
'Score: 0',
{
fontFamily: 'Arial',
fontSize: '18px',
fill: '#fff'
}
);
}
function createBricksGroup(name, y, scene) {
return scene.physics.add.group({
key: name,
repeat: 8,
immovable: true,
setXY: {
x: 80,
y: y,
stepX: 80
},
setScale: { x: 0.2, y: 0.2 }
});
}
function update() {
if (!gameWon) {
if (isGameOver(this.physics.world, this.ball)) {
this.gameOverText.setVisible(true);
this.ball.destroy()
this.player.destroy();
} else {
this.player.setX(this.input.x);
if (!gameStarted) {
this.ball.setX(this.player.x);
if (this.input.activePointer.isDown) {
gameStarted = true;
this.ball.setVelocityY(-300);
this.startText.setVisible(false);
}
}
}
}
}
function isGameOver(world, ball) {
return ball.body ? ball.body.y > world.bounds.height : true;
}
function isWon(blueBricks, greenBricks, purpleBricks, yellowBricks, redBricks) {
return (
blueBricks.countActive() === 0 &&
greenBricks.countActive() === 0 &&
purpleBricks.countActive() === 0 &&
yellowBricks.countActive() === 0 &&
redBricks.countActive() === 0
);
}
function brickCollision(ball, brick) {
brick.destroy();
addScore(brick);
if (ball.body.velocity.x === 0) {
if (ball.x < brick.x) {
ball.body.setVelocityX(-130);
} else {
ball.body.setVelocityX(130);
}
}
if (isWon(this.blueBricks, this.greenBricks, this.purpleBricks, this.yellowBricks, this.redBricks)) {
this.winText.setVisible(true);
this.ball.destroy();
this.player.destroy();
gameWon = true;
}
}
function playerCollision(ball, player) {
var velX = Math.abs(ball.body.velocity.x);
if (ball.x < player.x) {
ball.setVelocityX(-velX);
} else {
ball.setVelocityX(velX);
}
}
function addScore(brick) {
switch (brick.texture.key) {
case "brick1":
score += 10;
break;
case "brick2":
score += 20;
break;
case "brick3":
score += 30;
break;
case "brick4":
score += 40;
break;
case "brick5":
score += 50;
break;
}
scoreText.setText('Score: ' + score);
}
</script>
</body>
</html>