Platformer game in JavaScript using PIXI.js

Muhammad Saqib Ilyas
25 min readApr 12, 2024

--

In an earlier article, I showed how to create the system code for a platformer game in JavaScript using the PIXI.js library. This game is my attempt to port the code given here by someone else to PIXI.JS v8. In this article, I’ll show how to create the rest of the game.

I’ve also written an article to create a Space Shooter game and a clone of Doodle Jump using this approach.

Here’s what the finished game looks like. You can play the game, here.

You may download the complete code from this repository, or build it gradually by following along. Let me know if I missed explaining some step. Feedback is always welcome.

Creating a platform

We’ll create a platform to place the hero on. This platform is configurable in dimensions and position. It is based on a tile image tile.png and platform.png which are placed in the src/sprites directory. Each platform is created by placing multiple instance of these image side by side in rows and columns. The file platform.png looks like grass, whereas the tile.png file looks like a rock. So, we will place instances of platform.png on the top row of the platform, and all other rows will be based on tile.png.

We create a Platform class in the src/scripts/game directory.

import * as PIXI from "pixi.js";
import { App } from '../system/App';

export class Platform {
constructor(rows, cols, x) {
this.rows = rows;
this.cols = cols;
this.tileSize = App.sprite("tile").width
this.width = this.tileSize * this.cols;
this.height = this.tileSize * this.rows;
this.createContainer(x)
this.createTiles()
}

createContainer(x) {
this.container = new PIXI.Container();
this.container.x = x;
this.container.y = window.innerHeight - this.height;
}

createTiles() {
for (let row = 0; row < this.rows; row++) {
this.createRowofTiles(row)
}
}

createRowofTiles(row) {
for (let col = 0; col < this.cols; col++) {
this.createTile(row, col);
}
}

createTile(row, col) {
const texture = row === 0 ? "platform" : "tile"
const tile = App.sprite(texture);
this.container.addChild(tile);
tile.x = col * tile.width;
tile.y = row * tile.height;
}
}

In the constructor, we accept the number of rows, and columns, as well as the position of the platform as arguments. We store these in member variables. We get the tile sprite from the Application class instance and query its dimensions. The image itself is square, so its width and height are identical. We create the platform’s total width and height using the title size, the number of rows and columns. We create a container in which the platform is to be placed. The x position of the platform container is according the position argument passed to the constructor. As for the y position, we want to place the platform on the game floor, so we calculate the y position of the platform (the top left corner) using the window height and the platform height.

Then, we call a createTiles() method to create the titles for the platform. In a loop, we call a createRowofTiles() method to create one row of tiles. We pass in the current row number so that the y position of the tiles can be determined. In the createRowofTiles() method, in a loop, we call a createTile() method which creates one tile. If the current row number is 0, that is, we are rendering the top row of the platform, we query the “platform” sprite, otherwise, we query the “tile” sprite, and add it to the container at the specific location.

At this point, the game should look like this:

The game look with the current code

Creating the hero

Let’s now add the hero to the mix. The hero is based on the walk1.png, and walk2.png files animated in a loop to create an animated walk feel. To render the hero, we create a src/scripts/game/Hero.js file.

import * as PIXI from "pixi.js";
import { App } from '../system/App';

export class Hero {
constructor() {
this.createSprite();
}

createSprite() {
this.sprite = new PIXI.AnimatedSprite([
App.res("walk1"),
App.res("walk2")
]);

this.sprite.x = App.config.hero.position.x;
this.sprite.y = App.config.hero.position.y;
this.sprite.loop = true;
this.sprite.animationSpeed = 0.1;
this.sprite.play();
}
}

We create an AnimatedSprite object based on the walk1.png and walk2.png files. We position the hero based on settings defined in Config. We set sprite.loop to true so that the sprite keeps walking. We set the animation speed, and call the play() method.

We modify the Config class to:

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png)$/)),
bgSpeed: 2,
scenes: {
"Game": GameScene,
"startScene": Game
},
hero: {
position: {
x: 350,
y: 595
}
}
}

We also modify the GameScene class to:

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platform } from './Platform';
import { Hero } from './Hero';

export class GameScene extends Scene {
create() {
this.createBackground();
this.createPlatform({rows: 4, cols: 6, x: 200})
this.createHero()
}

/* Other code */

createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.sprite);
}
}

Here’s what our game looks like now:

Our hero has started walking

We can adjust the hero position in Config.js so that it appears to be at the top of the platform.

Creating an endless stream of platforms

We need multiple platforms and we need them to be created over and over. For that, let’s create a Platforms class in src/scripts/game.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Platform } from "./Platform";

export class Platforms {
constructor() {
this.platforms = [];
this.container = new PIXI.Container();
}
}

We initialize an empty array, named platforms, to hold the platforms to be created later. We create a PIXI.Container instance to place all the platforms in.

We’ll remove the hard-coded platform creation code from GameScene class, and replace it with an instantiation of a Platforms class instance.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';

export class GameScene extends Scene {
create() {
this.createBackground();
this.createPlatforms()
this.createHero()
}

/* Other code */

createPlatforms(data) {
this.platforms = new Platforms();
this.container.addChild(this.platforms.container);
}

/* Other code */
}

At this point, the platform we created earlier has disappeared. If we modify the Platforms class as follows, it will appear again.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Platform } from "./Platform";

export class Platforms {
constructor() {
this.platforms = [];
this.container = new PIXI.Container();
this.createPlatform({
rows: 4,
cols: 6,
x: 200
});
}

createPlatform(data) {
const platform = new Platform(data.rows, data.cols, data.x);
this.container.addChild(platform.container);
}
}

We define a createPlatform() method and call it from the constructor. Now, the platform will be visible, again. But, that’s not what we are aiming for. Let’s add configuration in the Config class for platform locations and sizes. Then, we’ll create them in the Platforms class. We modify the Config class as follows with minimum and maximum values for the number of rows, columns, and the offset from the game floor.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png|mp3)$/)),
bgSpeed: 2,
scenes: {
"Game": GameScene,
"startScene": Game
},
hero: {
position: {
x: 350,
y: 595
}
},
platforms: {
ranges: {
rows: {
min: 2,
max: 6
},
cols: {
min: 3,
max: 9
},
offset: {
min: 60,
max: 200
}
}
},
}

Let’s add a getRandomData() method to the Platforms class to generate random dimensions and location for a new platform based on the above range of values. We’ll create a new platform if there is space to the right of the current platform, i.e., the right edge of the current platform is to the left of the right edge of the screen.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Platform } from "./Platform";

export class Platforms {
constructor() {
this.platforms = [];
this.container = new PIXI.Container();
this.createPlatform({
rows: 4,
cols: 6,
x: 200
})
this.ranges = App.config.platforms.ranges;
}

createPlatform(data) {
const platform = new Platform(data.rows, data.cols, data.x);
this.current = platform
this.container.addChild(platform.container);
this.platforms.push(platform)
}

getRandomData() {
let data = { };

const offset = this.ranges.offset.min + Math.round(Math.random() * (this.ranges.offset.max - this.ranges.offset.min));

data.x = this.current.container.x + this.current.container.width + offset;
data.cols = this.ranges.cols.min + Math.round(Math.random() * (this.ranges.cols.max - this.ranges.cols.min));
data.rows = this.ranges.rows.min + Math.round(Math.random() * (this.ranges.rows.max - this.ranges.rows.min));
return data;
}

update() {
if (this.current.container.x + this.current.container.width < window.innerWidth) {
this.createPlatform(this.getRandomData());
}
}
}

In the constructor, we query the ranges data defined in Config and store these in this.ranges. In the createPlatform() method, we store the new platform in this.current. In getRandomData(), we create an empty object named data. We generate random values for the vertical offset, the x offset, and the dimensions of the new platform to be created, storing these in the data object. Finally, we return the data object.

In the update() method, we check if the current container has some space to its right, and if so, we create a new platform using random data. Now, if you run the program, in addition to the statically generated platform, you’ll see a random number of platforms of different sizes each time you refresh the page.

Adding physics

PIXI.js is a rendering library and as such does not provide game physics implementation. Game physics deals with falling under gravity, handling of collisions between objects etc. Matter.js is a library that we can use for that purpose. Add this library to the dependencies in package.json or install it using the command npm install --save matter-js.

Next, fix the hero’s position so that it is above the statically created platform. For that, modify Config.js as follows:

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
/* Other code */
hero: {
position: {
x: 350,
y: 395
},
jumpSpeed: 15,
maxJumps: 2
},
/* Other code */
}

This way, once physics is enabled, the hero wouldn’t immediately fall under gravity out of view.

Enabling physics

The next step is to enable physics in our game Application class:

import * as PIXI from "pixi.js";
import { Loader } from "./Loader"
import { ScenesManager } from "./ScenesManager";
import * as Matter from 'matter-js';

class Application {
run(config) {
this.config = config;
this.app = new PIXI.Application();
this.config.stage = this.app.stage;
this.app.init({ width: window.innerWidth, height: window.innerHeight }).then(()=> {
document.body.appendChild(this.app.canvas);
this.loader = new Loader(this.config);
this.loader.preload().then(() => this.start());
this.createPhysics()
})
}
/* Other code */

createPhysics() {
this.physics = Matter.Engine.create()
const runner = Matter.Runner.create()
Matter.Runner.run(runner, this.physics)
}
}

export const App = new Application();

In the run() method, we invoke the createPhysics() method which creates the Matter engine, creates a Runner and then runs it. We save the Matter engine to the this.physics property so that the application can interact with the engine. Having done this, we need to introduce the objects in our game to the physics engine. This involves creating “Bodies” for the sprites and adding them to the physics engine.

Platform bodies

First, we modify the Platform class as follows:

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Platform {
constructor(rows, cols, x) {
/* Other code */
this.dx = App.config.platforms.moveSpeed
this.createBody()
}

/* Other code */

createBody() {
this.body = Matter.Bodies.rectangle(this.width / 2 + this.container.x, this.height / 2 + this.container.y, this.width, this.height, {friction: 0, isStatic: true})
Matter.World.add(App.physics.world, this.body)
this.body.gamePlatform = this
}

move() {
if (this.body) {
Matter.Body.setPosition(this.body, {x: this.body.position.x + this.dx, y: this.body.position.y})
this.container.x = this.body.position.x - this.width / 2
this.container.y = this.body.position.y - this.height / 2
}
}
}

We read the horizontal movement velocity from the Config class into a dx property. This helps update the platform’s position to make it appear to scroll across the screen. We call a createBody() method. In this method, we create a rectangle object centred at the centre of the platform sprite, with the same dimensions as the sprite. At the same time, we set friction to 0, and isStatic to true. When we set friction to 0, we indicate that there is no friction between this body and any other bodies. In other words, this body will keep moving indefinitely. The value of friction can be between 0 and 1. Setting the isStatic property to true indicates that we don’t want to move this body arbitrarily, like through keyboard controls. We save the newly created rectangle object in this.body. We add this to the Matter engine’s world saved in App.physics.world. We add the current object’s reference in the physical body.

We also define a move() method to update the x location of the platform’s body. The y location does not change, since the platforms are supposed to only scroll horizontally across the screen. To enable the rendering engine to display the scrolling platform, we update the location in this.container. Since the body’s x and y locations refer to the center of the rectangle, whereas the this.container expects to point to the top left corner of the sprite, we adjust based on half the rectangle’s width, and height.

The following corresponding change is required in Config.js:

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
platforms: {
/* Other code */
moveSpeed: -1.5
}
}

Since the platform should scroll from right to left, the value of moveSpeed is negative.

We update the Platforms class so that it updates the position of all the Platform objects.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Platform } from "./Platform";

export class Platforms {
/* Other code*/

update() {
/* Other code*/
this.platforms.forEach(platform => platform.move());
}
}

Now, if you run the program, the platforms should start scrolling from right to left. As the platforms disappear on the left, new platforms will emerge randomly.

Hero’s body

Now, we create the body for the hero as well. Unlike the platforms, the hero will fall under the effect of gravity, so isStatic will be set to false (which is the default).

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
constructor() {
this.createSprite();
this.createBody()
App.app.ticker.add(this.update.bind(this))
}

createBody() {
this.body = Matter.Bodies.rectangle(this.sprite.x + this.sprite.width / 2, this.sprite.y + this.sprite.height / 2, this.sprite.width, this.sprite.height, {friction: 0})
Matter.World.add(App.physics.world, this.body)
this.body.gameHero = this
}

update() {
this.sprite.x = this.body.position.x - this.sprite.width / 2
this.sprite.y = this.body.position.y - this.sprite.height / 2
}
}

In the constructor, note the this.update.bind(this) as the parameter to App.app.ticker.add(). Why couldn’t this parameter simply be this.update? The reason is that the update() method uses the this pointer to access the sprite location, which would not work if the ticker invoked it. The this pointer in this case would point to the object that invokes it, i.e., the ticker. The ticker, doesn’t have a sprite property. The bind(this) part makes sure that the method is bound to the correct execution context.

Now, if you run the program, the hero will walk across the first platform and fall to its unfortunate demise.

Implementing the hero’s jump

We need to enable the hero to jump across platforms. Let’s implement the jump. Making the hero jump is a matter of doing something like Matter.Body.setVelocity(this.body, {x: 0, y: -15}). This sets a negative y velocity on the hero’s body, making it appear to move upwards. However, we don’t want to allow the player to do this indefinitely, otherwise, they can make the hero jump again and again while it is in the air. Let’s limit this to being able to jump at most twice at a time. For that, let’s define a parameter in Config.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
/* Other code */
hero: {
position: {
x: 350,
y: 395
},
jumpSpeed: 15,
maxJumps: 2
}
/* Other code */
}

Then, we may update the Hero class as:

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
constructor() {
/* Other code */
this.dy = App.config.hero.jumpSpeed
this.maxJumps = App.config.hero.maxJumps
this.jumpIndex = 0
}

/* Other code */

startJump() {
if (this.jumpIndex < this.maxJumps) {
this.jumpIndex++
Matter.Body.setVelocity(this.body, {x: 0, y: -this.dy})
}
}
}

We save the value of the jumpSpeed parameter in this.dy. We set a jumpIndex property to 0. This will keep track of how many times the hero has jumped simultaneously. We also declare a maxJumps property to hold the maximum allowable simultaneous number of jumps. We define a startJump() method in which we compare the current value of jumpIndex against maxJumps. If a jump is allowed, we increment jumpIndex, and ask the physics engine to make the hero appear to jump.

Now, we modify GameScene to hook the startJump method to a press of the up arrow key. We can directly call the window.AddEventListener() to subscribe to keyboard events. But, I find it convenient to use the script from this repository. We modify the src/scripts/system/Tools.js file as follows.

export class Tools {
static importAll(r) {
return r.keys().map(key => r(key))
}

static keyboard(value) {
const key = {};
key.value = value;
key.isDown = false;
key.isUp = true;
key.press = undefined;
key.release = undefined;
//The `downHandler`
key.downHandler = (event) => {
if (event.key === key.value) {
if (key.isUp && key.press) {
key.press();
}
key.isDown = true;
key.isUp = false;
event.preventDefault();
}
};

//The `upHandler`
key.upHandler = (event) => {
if (event.key === key.value) {
if (key.isDown && key.release) {
key.release();
}
key.isDown = false;
key.isUp = true;
event.preventDefault();
}
};

//Attach event listeners
const downListener = key.downHandler.bind(key);
const upListener = key.upHandler.bind(key);

window.addEventListener("keydown", downListener, false);
window.addEventListener("keyup", upListener, false);

// Detach event listeners
key.unsubscribe = () => {
window.removeEventListener("keydown", downListener);
window.removeEventListener("keyup", upListener);
};

return key;
}
}

Now, in src/scripts/game/GameScene.js, we subscribe to the up arrow key press.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';
import * as Matter from 'matter-js'
import {App} from '../system/App'
import * as Tools from '../system/Tools'

export class GameScene extends Scene {
/* Other code */

createHero() {
/* Other code */
const up = Tools.Tools.keyboard('ArrowUp')
up.press = this.hero.startJump.bind(this.hero)
this.hero.sprite.once('die', ()=> {
App.scenes.start('Game')
})
}

/* Other code */
}

In the createHero() method we hook up a up event to the startJump() method in this.hero. Now, if you run the program, pressing the up arrow key will make the hero jump. Releasing the up arrow and pressing it again will make the hero jump mid-air. Any further up arrow key presses will have no effect on the hero’s position. But, two jumps is all you get. Ultimately, the hero will fall to its demise. We need to somehow reset the jumpIndex counter to allow the hero to jump again, as long as it does not fall to its demise.

The moment the hero lands on a platform after a jump is when we should reset jumpIndex. The matter.js engine allows us to detect collisions between objects, so let’s detect collisions between the hero and a platform.

We modify the GameScene class to listen to collisions:

/* Other code */
import * as Matter from 'matter-js'
import {App} from '../system/App'

export class GameScene extends Scene {
create() {
/* Other code */
this.registerEvents()
}

registerEvents() {
Matter.Events.on(App.physics, 'collisionStart', this.onCollisionStart.bind(this))
}

onCollisionStart(event) {
const colliders = [event.pairs[0].bodyA, event.pairs[0].bodyB]
const hero = colliders.find((body) => body.gameHero)
const platform = colliders.find((body) => body.gamePlatform)
if (hero && platform) {
this.hero.landOnPlatform(platform)
}
}

/* Other code */
}

We register a collisionStart event listener. The event object passed to the event listener has a pairs array, which holds a pair of objects for every collision detected. In our game, only one collision is expected to happen at a time, so we can safely look at pairs[0]. Each pairs entry holds the two bodies involved in the collision as bodyA, and bodyB. We are interested to know if one of these is the hero, and the other a platform. Recall that when we implemented physics on the Platform class, we did a this.body.gamePlatform = this. So, we put the two bodies into an array, and filter the array on body.gamePlatform, it will return the platform body. This is because for the hero, body.gamePlatform is undefined, which is falsy. We similarly extract the hero. If the collision involves both the hero and a platform, we call a landOnPlatform() method on the hero. Let’s implement that method on the Hero class.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
/* Other code */

landOnPlatform(platform) {
this.platform = platform
this.jumpIndex = 0
}
}

We update the this.platform property to point to the new platform that the hero is on, and reset the jumpIndex variable. Now, if you run the program, you’ll be able to jump again and again across multiple platforms.

Creating diamonds

Let’s now create diamonds above the platforms which the hero can collect by jumping. Let’s create a Diamond class in src/scripts/game/Diamond.js.

import { App } from '../system/App';
import * as Matter from 'matter-js';

export class Diamond {
constructor(x, y) {
this.createSprite(x, y)
}

createSprite(x, y) {
this.sprite = App.sprite("diamond");
this.sprite.x = x;
this.sprite.y = y;
}

createBody() {
this.body = Matter.Bodies.rectangle(this.sprite.width / 2 + this.sprite.x + this.sprite.parent.x, this.sprite.height / 2 + this.sprite.y + this.sprite.parent.y, this.sprite.width, this.sprite.height, {friction: 0, isStatic: true});
this.body.gameDiamond = this;
this.body.isSensor = true;
Matter.World.add(App.physics.world, this.body);
}

update() {
if (this.sprite) {
Matter.Body.setPosition(this.body, {x: this.sprite.x + this.sprite.width / 2 + this.sprite.parent.x, y: this.sprite.y + this.sprite.height / 2 + this.sprite.parent.y});
}
}
}

Notice how we defined a createBody() method which sets the diamond’s y coordinate with reference to its parent’s. The isSensor property set to true enables the hero to pass through the diamond, unlike the platform. We also define an update() method on the Diamond class so that as the view scrolls, the diamond’s location in the Matter.js engine is also updated.

Since diamonds are to hover above each platform, let’s modify the Platform class to create the diamonds.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
import { Diamond } from "./Diamond";

export class Platform {
constructor(rows, cols, x) {
/* Other code */
this.diamonds = []
this.createDiamonds()
}

/* Other code */

createDiamonds() {
const y = App.config.diamonds.offset.min + Math.random() * (App.config.diamonds.offset.max - App.config.diamonds.offset.min);

for (let i = 0; i < this.cols; i++) {
if (Math.random() < App.config.diamonds.chance) {
this.createDiamond(this.tileSize * i, -y)
}
}
}

createDiamond(x, y) {
const diamond = new Diamond(x, y);
diamond.createBody();
this.container.addChild(diamond.sprite);
this.diamonds.push(diamond);
}

move() {
if (this.body) {
Matter.Body.setPosition(this.body, {x: this.body.position.x + this.dx, y: this.body.position.y})
this.container.x = this.body.position.x - this.width / 2
this.container.y = this.body.position.y - this.height / 2
this.diamonds.forEach( (diamond) => diamond.update())
}
}
}

We create an array to store the diamonds for the platform. We query configuration parameters for the minimum, and maximum offset above the platform at which the diamond should be placed. Another parameter queried is the probability of having a diamond above a given column within a platform. For each column, with a certain probability, we create a new Diamond object at a specific x and y location. We call the createBody() method on the diamond so that physics is enabled on it. We add the diamond to the PIXI.js container, and add the diamond to the diamonds array. We also call the move() method on the Diamond objects in the move() method of the Platform class.

We define the diamond parameters in Config.js.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
/* Other code */
diamonds: {
chance: 0.4,
offset: {
min: 100,
max: 200
}
}
}

Now, if you run the program, you should see diamonds randomly splayed above some of the platforms.

Collecting the diamonds

When the hero collides with a diamond, it should collect the diamond. We’ve already detected collisions in the GameScene class. Let’s extend that event handler to handle diamond collection.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';
import * as Matter from 'matter-js'
import {App} from '../system/App'

export class GameScene extends Scene {
/* Other code */

onCollisionStart(event) {
const colliders = [event.pairs[0].bodyA, event.pairs[0].bodyB]
const hero = colliders.find((body) => body.gameHero)
const platform = colliders.find((body) => body.gamePlatform)
const diamond = colliders.find( (body) => body.gameDiamond)
if (hero && platform) {
this.hero.landOnPlatform(platform)
}
else if (hero && diamond) {
this.hero.collectDiamond(diamond.gameDiamond)
}
}
/* Other code */
}

We detect collisions with a diamond and call a collectDiamond() method on the Hero class object. Let’s define that method.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
constructor() {
/* Other code */
this.score = 0
}
/* Other code */
collectDiamond(diamond) {
Matter.World.remove(App.physics.world, diamond.body)
diamond.sprite.destroy()
diamond.sprite = null
this.score += 10
}
}

We initialize a score to 0 in the constructor. We remove the diamond body from the Matter.js engine, and destroy the PIXI.js Sprite object. We also increment the score by 10 for each diamond collected.

Displaying the score

Let’s now display and update the score on the screen. First, we define a LabelScore class which extends the PIXI.Text class.

import * as PIXI from "pixi.js";
import { App } from "../system/App";

export class LabelScore extends PIXI.Text {
constructor() {
super();
this.x = App.config.score.x;
this.y = App.config.score.y;
this.anchor.set(App.config.score.anchor);
this.style = App.config.score.style;
this.renderScore();
}

renderScore(score = 0) {
this.text = `Score: ${score}`;
}
}

We set the x and y locations for the score label. We read the label position and text style from Config.js. We set the text property to the score text to display it on the screen. The corresponding changes in Config.js are as follows.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png|mp3)$/)),
bgSpeed: 2,
score: {
x: 10,
y: 10,
anchor: 0,
style: {
fontFamily: "Verdana",
fontWeight: "bold",
fontSize: 44
}
},
/* Other code */
}

We create an instance of the LabelScore class in the GameScene class.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';
import * as Matter from 'matter-js'
import {App} from '../system/App'
import { LabelScore } from "./LabelScore";

export class GameScene extends Scene {
create() {
this.createBackground();
this.createPlatforms()
this.createHero()
this.registerEvents()
this.createUI()
}

/* Other code */

createUI() {
this.labelScore = new LabelScore();
this.container.addChild(this.labelScore);
this.hero.sprite.on("score", () => {
this.labelScore.renderScore(this.hero.score);
});
}
}

We import the LabelScore class, create a createUI() method, and call it from the create() method. We subscribe to a score event and invoke the renderScore() method from the LabelScore class. That event should be fired when there’s a collision between the hero and a diamond.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
/* Other code */

collectDiamond(diamond) {
/* Other code */
this.sprite.emit("score")
}
}

In the collectionDiamond() method, we emit the score event. Now, if you run the program, your score should be updated when the hero collects a diamond.

Ending the game

All that remains to do is to restart the game when the hero falls to its unfortunate demise. First, we modify the GameScene class as follows.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';
import * as Matter from 'matter-js'
import {App} from '../system/App'
import { LabelScore } from "./LabelScore";

export class GameScene extends Scene {
/* Other code */

createHero() {
/* Other code */
this.hero.sprite.once('die', ()=> {
App.scenes.start('Game')
})
}

/* Other code */
}

We modify the createHero() method to register an event listener to listen to a custom die event on the sprite member of the Hero class instance. In other words, when this.hero.sprite emits the die event, the following code will be run once only. That code is an anonymous function that calls the App.scenes.start() method (from the ScenesManager class) which we wrote earlier. That code sees that this.scene evaluates to true and calls this.scene.remove(). This calls the destroy() method of the GameScene class. We modify that method as follows.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';
import * as Matter from 'matter-js'
import {App} from '../system/App'
import { LabelScore } from "./LabelScore";

export class GameScene extends Scene {
/* Other code */

destroy() {
Matter.Events.off(App.physics, 'collisionStart', this.onCollisionStart.bind(this));
App.app.ticker.remove(this.update, this);
this.hero.destroy()
this.bg.destroy();
this.platforms.destroy()
this.labelScore.destroy()
}
}

We turn off the collisionStart event handling in the physics engine, and remove the GameScene class from its ticker for periodic updates. We then call the destroy() methods on the Hero, Background, and the Platforms class objects. Here is what they look like. First, the Hero class.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Hero {
/* Other code */

update() {
if (this.sprite) {
this.sprite.x = this.body.position.x - this.sprite.width / 2
this.sprite.y = this.body.position.y - this.sprite.height / 2
}
if (this.sprite && (this.sprite.position.y - window.innerHeight > 0.1)) {
this.sprite.emit("die");
}
}

/* Other code */

collectDiamond(diamond) {
if (this.sprite) {
Matter.World.remove(App.physics.world, diamond.body)
diamond.sprite.destroy()
diamond.sprite = null
this.score += 10
this.sprite.emit("score")
}
}

destroy() {
App.app.ticker.remove(this.update, this)
Matter.World.remove(App.physics.world, this.body)
this.sprite.destroy()
this.sprite = null
}
}

We remove this class’ update() method from the physics engine ticker, and remove the hero’s body from the physics engine. We destroy the AnimatedSprite object, and set this.sprite to null. We also modify the collectDiamond() method to only run its code if the sprite property is not null. We apply a similar check in the update() method. Also, we emit the die event if the hero has fallen below the game floor.

We modify the Background class as follows.

import * as PIXI from "pixi.js";
import { App } from "../system/App";

export class Background {
/* Other code */

destroy() {
this.container.destroy()
}
}

We modify the Platforms class as follows.

import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Platform } from "./Platform";

export class Platforms {
/* Other code */

destroy() {
this.platforms.forEach( (platform) => platform.destroy())
this.container.destroy()
}
}

In addition to destroying the PIXI.js Container, we invoke the destroy() method on the Platform class. Accordingly, we modify the Platform class as follows.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
import { Diamond } from "./Diamond";

export class Platform {
/* Other code */

destroy() {
Matter.World.remove(App.physics.world, this.body)
this.diamonds.forEach((diamond) => diamond.destroy())
this.container.destroy()
}
}

We remove the body attached to the platform from the physics engine and call the destroy() method on each of the Diamond objects in the diamonds array. Accordingly, we modify the Diamond class as follows.

import { App } from '../system/App';
import * as Matter from 'matter-js'

export class Diamond {
/* Other code */

destroy() {
if (this.sprite) {
App.app.ticker.remove(this.update, this)
Matter.World.remove(App.physics.world, this.body)
this.sprite.destroy()
this.sprite = null
}
}
}

If the sprite property is not null, we remove the body attached to this diamond from the physics engine and unsubscribe from the ticker. We call the destroy() method on the Sprite instance, and set that property to null.

Now, if you run the program, falling off a platform starts a new game with score reset to zero.

Adding sound

What’s a game without sound. I downloaded a couple of free sound files from pixabay and placed those in the src/sprites/ directory. I renamed one as jump.mp3 and the other as collect.mp3. First, install @pixijs/sound module using the command npm install --save @pixi/sound. Now, we modify src/scripts/game/Config.js as follows.

import { Tools } from "../system/Tools";
import { GameScene } from "./GameScene";
import { Game } from "./Game";

export const Config = {
loader: Tools.importAll(require.context('./../../sprites', true, /\.(png|mp3)$/)),
/* Other code */
}

Next, we modify the Loader class as follows.


import {Assets} from "pixi.js"
import {sound} from '@pixi/sound'
export class Loader {
/* Other code */

async preload() {
try {
await Promise.all(
this.sprites.map(async (fileModule) => {
let filePath = fileModule.default;
const texture = await Assets.load(filePath);
const indexOfSlash = filePath.lastIndexOf('/')
filePath = filePath.substr(indexOfSlash + 1)
const indexOfDot = filePath.lastIndexOf('.')
const extension = filePath.substr(indexOfDot + 1)
const fileName = filePath.substr(0, indexOfDot)
if (extension.toLowerCase() === 'mp3') {
if (sound.exists(fileName)) {
sound.remove(fileName)
}
sound.add(fileName, fileModule.default)
}
else {
this.resources[fileName] = texture; // Store loaded textures
}
})
);
} catch (error) {
console.error("Error loading assets:", error);
}
}
}

We import the PIXI sound module. We modify the await block to load the mp3 files to the PIXI sound module’s sounds collection. We do this by checking for the file extension being .mp3. If a sound with the same name is already added, we remove it, first. Next, we need to add code to play the sounds. We modify the Hero class as follows.

import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
import {sound} from '@pixi/sound'

export class Hero {
startJump() {
if (this.jumpIndex < this.maxJumps) {
/* Other code */
sound.play('jump')
}
}

collectDiamond(diamond) {
if (this.sprite) {
/* Other code */
sound.play('collect')
}
}
}

We import the PIXI sound module and insert a sound.play() call to play the jump sound in the startJump() method, and another one in the collectDiamond() method to play the collect sound. Now, if you run the program, you should hear these sounds when jumping and collecting the diamonds.

Implementing camera follow

If you play the game long enough, eventually you’d notice that when the hero hits a platform on the edge, it “falls behind” a little bit. If this happens enough times, the hero will vanish from the view. That’s not good. To fix that, we need to implement camera follow. It sounds scary, but fortunately, it is super-simple for us. All we need to do is add a line of code to the update() method of the GameScene class.

/* Imports */

export class GameScene extends Scene {
/* Other code */

update(dt) {
/* Other code */
this.container.pivot.x = this.hero.sprite.x - App.config.hero.position.x
}
}

All we needed to do is to pivot the main Container object to a few pixels behind the hero.

However, this creates some other problems. Don’t worry. That’s normal in software development. Fixing one error often leads to others. We just need to be patient and carefully look for the reasons why that happened.

One problem is that we get some broken background artifacts near the left edge of the screen sometimes. What happens is that when we change the pivot, we end up exposing a part of the Container that does not have the background. Recall that we have three copies of the background sprite. We can add another one to the left of the visible part of the screen. That way, when we set pivot to a negative x value, there’d still be some background to display there.

/* Imports */

export class Background {
constructor() {
/* Same old */
}
createSprites() {
this.sprites = [];

for (let i = 0; i < 4; i++) {
this.createSprite(i);
}
}

createSprite(i) {
const sprite = App.sprite("bg");

sprite.x = sprite.width * (i-1);
sprite.y = 0;
this.container.addChild(sprite);
this.sprites.push(sprite);
}

move(sprite, offset) {
const spriteRightX = sprite.x + sprite.width;

const screenLeftX = -sprite.width;

if (spriteRightX <= screenLeftX) {
sprite.x += sprite.width * this.sprites.length;
}

sprite.x -= offset;
}

/* Other code */
}

In createSprites(), we changed the loop limit to 4. In createSprite(), we changed the way the x coordinate is calculated. Now, the x coordinate for the first sprite is (instead of 0) equal to -n where n is the width of the background image. The x coordinate of each subsequent copy of the background is in increments of n. Also, previously we were wrapping a sprite around to the right if its x coordinate reaches 0, but now, we are wrapping the sprite around if its x coordinate reaches -n.

That fixes the visible glitches in the background. On to the other problem. Now, you’ll notice that as we adjust the pivot, the score and lives remaining display get shifted. The issue is that we are adjusting the pivot on the main Container, but the score and lives display remain where they were affixed to the Container. To fix that we modify the GameScene class as follows.

import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Platforms } from './Platforms';
import { Hero } from './Hero';
import * as Matter from 'matter-js'
import {App} from '../system/App'
import { LabelScore } from "./LabelScore";
import * as Tools from '../system/Tools'
import {stats} from './Stats'
import { GameOver } from "./GameOver";
import { LivesScore } from "./LivesScore";

export class GameScene extends Scene {
/* Other code */

update(dt) {
super.update(dt)
this.bg.update(dt.deltaTime);
this.platforms.update()
const scorePosition = this.container.toGlobal(this.labelScore.position)
const livesPosition = this.container.toGlobal(this.livesScore.position)
this.container.pivot.x = this.hero.sprite.x - App.config.hero.position.x
const newPosition = this.container.toLocal(scorePosition)
this.labelScore.x = this.container.toLocal(scorePosition).x
this.livesScore.x = this.container.toLocal(livesPosition).x
}
/* Other code */
}

After updating the platforms, and before adjusting the pivot, we obtain the global coordinates of the score and lives remaining displays. This gives us the global coordinates with respect to the old position of the Container. We then adjust the pivot. Next, we obtain the local coordinates of the two visual elements, given their global coordinates, with respect to the new position of the Container. Since only the x coordinate is being adjusted in the pivot change, we only adjust the x coordinates of the score and lives remaining displays. That makes sure that the score and lives remaining displays remain in the same relative position from the top left corner of the screen no matter what.

Next steps

Take this game to the next level. Make the game gradually more difficult. For instance, on crossing every thousand points score threshold, increase the game speed. Or, add levels to the game where once the player scores 1000 points, they move to the next level where they have to avoid hitting birds flying overhead while trying to collect the diamonds. Also, create other games using the same template.

--

--

Muhammad Saqib Ilyas

A computer science teacher by profession. I love teaching and learning programming. I like to write about frontend development, and coding interview preparation