A Doodle Jump clone in JavaScript
Let’s create a clone of the famous game Doodle Jump in JavaScript. You may play the finished game here. I’ll use Pixi.js and Matter.js to build this game. I’ll use the same starter template developed in this article. I’ve written another article showing how to build another platformer, and a space shooter game earlier.
To start off, clone the code from this repository.
Resize the screen
Unlike the game developed in that template, Doodle jump is not a full-screen game. It has a tall and naroow screen. We’ll define the screen width and height in src/scripts/game/Config.js
. Modify the contents of that file:
/* Imports */
export const Config = {
/* Other code */
screen: {
width: 300,
height: 600
}
}
Modify the Application
class to use this screen size:
/* Imports */
class Application {
run(config) {
/* Other code */
this.app.init({ width: this.config.screen.width, height: this.config.screen.height }).then(()=> {
/* Other code */
})
}
/* Other code */
}
export const App = new Application();
Stop the background scrolling
The background is horizontally scrolling as it needed to for the platformer game. We don’t want that for this game, so let’s stop it from scrolling. For that, modify the GameScene
class.
/* Imports */
export class GameScene extends Scene {
/* Other code */
update(dt) {
super.update(dt)
}
}
Now, the background should be static. We don’t need the three copies of the background image in the Background
class, but we’ll leave it for now.
Add a Hero class
For the hero sprite, I downloaded a free sprite sheet from craftpix. It has various frames showing a creature jumping. I’ll just use one of those for frames for now.
Create a new file src/scripts/game/Hero.js
.
import * as PIXI from 'pixi.js'
import {App} from '../system/App.js'
export class Hero {
constructor(x, y) {
this.x = x
this.y = y
this.vy = App.config.hero.velocity
}
async createSprite() {
const atlasData = {
frames: {
straight: {
frame: { x: 30, y:50, w:70, h:78 },
}
},
meta: {
image: '../sprites/Jump.png',
format: 'RGBA8888',
size: { w: 1408, h: 128 },
scale: 1
},
}
this.spritesheet = new PIXI.Spritesheet(App.res('Jump'), atlasData)
await this.spritesheet.parse()
this.sprite = new PIXI.Sprite(this.spritesheet.textures.straight)
this.x = this.x - this.sprite.width / 2
this.sprite.x = this.x
this.sprite.y = this.y
}
}
Our Hero
class constructor accepts x
and y
parameters as argument, to serve as the coordinates to place the hero initially at. We store these values in x
and y
attributes of the instance. We also read the hero’s jump initial velocity from the game configuration. We define a createSprite()
method which parses the sprite sheet. It is defined as an async
method since it has an asynchronous function call to the SpriteSheet
’s parse()
method with the await
keyword.
In the createSprite()
method, we define an atlas data object to parse the sprite sheet. It has a frames
list containing just one frame
for now. We name it straight
. It is identified by the x and y coordinates of the top left corner, as well as the width and height. Some metadata is also included in this object. We pass this atlas data along with the Texture
from the sprite sheet to the SpriteSheet
constructor. Once the sprite sheet has been parsed with the atlas data, we read the one frame that we wanted to display into the sprite
attribute. Finally, we set the x and y coordinates for the sprite
. Note that if we set this.sprite.x
equal to the x
argument of the constructor, the hero would not appear in the centre of the screen, but slightly to the right. This is because x
is equal to half the screen’s width, and this.sprite.x
represents the x coordinate of the top left corner of the sprite. Adjusting it by half the sprite’s width fixes this. Note that we couldn’t know this adjustment in the GameScene
class when instantiating the Hero
, since the sprite had not been loaded yet.
Also, add the hero’s velocity setting to src/scripts/game/Config.js
.
/* Imports */
export const Config = {
/* Other code */
hero: {
velocity: 10
}
}
Display the hero
To display the hero on the screen, we need to modify the GameScene
class.
/* Other imports */
import { Hero } from "./Hero";
import { App } from "../system/App";
export class GameScene extends Scene {
create() {
this.createBackground();
this.createHero(App.config.screen.width / 2, App.config.screen.height * 3 / 4)
}
/* Other code */
async createHero(x, y) {
this.hero = new Hero(x, y )
await this.hero.createSprite()
this.container.addChild(this.hero.sprite)
}
/* Other code */
}
We import the class Hero
into the class GameScene
. In the create()
method, we add a call to a createHero()
method. Now, we should see the hero horizontally centred and above the bottom of the screen by about a quarter of the screen’s height.
Enable physics
Let’s now enable physics so that the hero falls under gravity. For that, we’ll install Matter.js. Add it to the dependencies in package.json
.
{
/* Other code */"dependencies": {
/* Other dependencies */
"matter-js": "^0.19.0"
},
/* Other code */
}
In the top-level directory of the project, run the command npm install
. This installs Matter.js. Now, we modify the Application
class.
/* Other imports */
import * as Matter from 'matter-js'
class Application {
run(config) {
/* Other code */
this.app.init({ width: this.config.screen.width, height: this.config.screen.height }).then(()=> {
/* Other code */
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();
We import the Matter.js library. In the init()
method callback, we add a call to a createPhysics()
method. This method creates a new physics engine and stores it in the physics
attribute. We create a Runner
object and run it. Now, we need to create a Body
object corresponding to the hero and add it to the physics engine.
/* Other imports */
import * as Matter from 'matter-js'
export class Hero {
/* Other code */
createBody() {
this.body = Matter.Bodies.rectangle(this.x + this.sprite.width / 2, this.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
}
}
Note that we are creating a rectangular body. The coordinates of the rectangle for the physics engine have to be the centre of the object. That’s why in the call to Matter.Bodies.rectangle()
, we adjust the this.x
and this.y
with half the sprite’s width and height, respectively. We need to call this method from the GameScene
class.
/* Imports */
export class GameScene extends Scene {
/* Other code */
async createHero(x, y) {
/* Other code */
this.hero.createBody()
this.container.addChild(this.hero.sprite)
}
/* other code */
}
After the call to the createSprite()
method, we call the createBody()
method. You wouldn’t see any change in the game yet. The reason is that the sprite’s position is set in the constructor and doesn’t change. We need to connect it to the physics engine.
/* Imports */
export class Hero {
/* Other code */
update(dt) {
this.sprite.x = this.body.position.x - this.sprite.width / 2
this.sprite.y = this.body.position.y - this.sprite.height / 2
}
}
We implement the update()
method in the Hero
class and update the Sprite
’s position based on the Body
’s current position, adjusted by the width and height of the sprite. Recall that the Sprite
’s x and y coordinates are that of the top left corner, whereas the Body
’s coordinates are that of the centre. You wouldn’t see a change in the game yet, because the update()
method isn’t being called. Let’s fix that.
/* Imports */
export class GameScene extends Scene {
/* Other code */
update(dt) {
super.update(dt)
this.hero.update(dt)
}
}
We call the Hero
class update()
method from the GameScene
class update()
method. Now, when the game starts, the hero will fall and disappear from the screen.
Making the hero jump
The game should start with the hero jumping. Let’s enable that.
/* Imports */
export class Hero {
constructor(x, y) {
/* Other code */
this.vy = App.config.hero.velocity
}
startJump() {
Matter.Body.setVelocity(this.body, {x: 0, y: -this.vy})
}
}
We configure a y velocity variable based on the configuration, and define a startJump()
method that sets the hero’s Body
’s y velocity to the negative of that. The reason is that y values decrease upwards, so to make the Body jump, we need to set a negative y velocity. We need to define the y velocity in the configuration.
/* Imports */
export const Config = {
/* Other code */
hero: {
velocity: 10
}
}
Now, we need to call the startJump()
method from the GameScene
class. Since the jump needs to happen right at the beginning of the game, we insert that call right after creating the hero object.
/* imports */
export class GameScene extends Scene {
/* Other code */
async createHero(x, y) {
/* Other code */
this.hero.startJump()
}
/* Other code */
}
Now, if you run the game, you’ll see the hero jump and then fall under gravity.
Creating platforms
Now, let’s focus on creating platforms for the hero to jump off. We create src/scripts/game/platform.js
with the following code.
import * as PIXI from 'pixi.js'
import { App } from '../system/App'
export class Platform {
constructor(x, y) {
this.x = x
this.y = y
this.createSprite()
}
createSprite() {
this.sprite = new PIXI.Sprite(App.res('Block'))
this.sprite.x = this.x
this.sprite.y = this.y
this.sprite.scale = 0.15
}
}
The constructor expects x and y coordinates for the platform’s placement. It calls a createSprite()
method that creates the Sprite
object, and sets its x and y coordinates. The image is rather big, so I scale it down.
Since there should usually be more than one platforms on the screen at a given time, let’s create a Platforms
class in src/scripts/game/Platforms.js
.
import { Platform } from "./Platform";
export class Platforms {
constructor() {
this.platforms = []
this.createPlatforms()
}
createPlatforms() {
const numPlatforms = 1
for (let i = 0 ; i < numPlatforms ; i++) {
const x = 100
const y = 450
this.platforms.push(this.createPlatform(x, y))
}
}
createPlatform(x, y) {
return new Platform(x, y)
}
}
In the constructor, we create an empty array, and call a createPlatforms()
method. For now, we set the number of platforms to create to 1, and initialize a fixed location for the sole platform. We call a createPlatform()
method which creates a Platform
object with the x and y coordinates. What remains to do now is to add the platform to the screen.
/* Other imports */
import { Platforms } from "./Platforms";
export class GameScene extends Scene {
create() {
/* Other code */
this.createPlatforms()
}
/* Other code */
createPlatforms() {
this.platforms = new Platforms()
this.platforms.platforms.forEach(platform => {
this.container.addChild(platform.sprite)
})
}
}
We add a createPlatforms()
method to GameScene
and call it from the create()
method. The method instantiates the Platforms
class. Then, it iterates over the platforms array to add all the Platform
sprites to the Container
object.
Now, once you run the game, you should see a platform on the screen.
Making the hero jump off the platform
To make the hero jump off a platform, we’ll get help from collision detection in the physics engine. Our hero class already has a Body
in the physics engine. Let’s give the platforms one as well.
We could add a createBody()
method to the Platform
class and call it at an appropriate place. However, much of the code would be identical to that in the Hero
class. That violates the don’t repeat yourself (DRY) principle. For this example, I don’t want to use inheritance, so I’ll just refactor the functionality into a separate utility class in src/scripts/game/MatterUtil.js
.
import * as Matter from 'matter-js'
export class MatterUtil {
createBody(x, y, width, height, world, options = {}) {
const body = Matter.Bodies.rectangle(x, y, width, height, options)
Matter.World.add(world, body)
return body
}
}
We define a MatterUtil class with a single method that creates a rectangular body in the physiscs engine, given its coordinates, dimensions and options. An example of this options object is the {friction: 0}
setting that we used in the Hero
class. First, we modify the Hero
class to use this method.
/* Other imports */
import { MatterUtil } from './MatterUtil.js'
export class Hero {
/* Other code */
createBody() {
const mu = new MatterUtil()
this.body = mu.createBody(this.x + this.sprite.width / 2, this.y + this.sprite.height / 2, this.sprite.width, this.sprite.height, App.physics.world, {friction: 0})
this.body.gameHero = this
}
}
We import the MatterUtil
class, create an instance of it, and call the createBody()
method, storing its returned Body
object in our instance variable this.body
.
Next, we use this method in the Platform
class.
import * as PIXI from 'pixi.js'
import { App } from '../system/App'
import { MatterUtil } from './MatterUtil'
export class Platform {
constructor(x, y) {
/* Other code */
const mu = new MatterUtil()
this.body = mu.createBody(this.x + this.sprite.width / 2, this.y + this.sprite.height / 2, this.sprite.width, this.sprite.height, App.physics.world, {isStatic: true, isSensor: true})
this.body.gamePlatform = this
}
/* Other code */
}
We remove the matter-js
import and import the MatterUtil
class, instead. We do this because no other method in Platform
requires access to the matter-js
library. Again, we create an instance of MatterUtil
, and create a Body
object by calling createBody()
. For the options object, we declare this object to be static, because we don’t want it to move when something hits it. We also set it to be a sensor body, meaning it should detect collisions, but not affect other bodies. If we don’t do this, the hero would hit a platform from below and rebound to its demise.
Now, we modify the GameScene
class to respond to collisions.
/* Imports */
export class GameScene extends Scene {
create() {
/* Other code */
this.registerEvents()
}
registerEvents() {
this.boundCollisionHandler = this.onCollisionStart.bind(this)
Matter.Events.on(App.physics, 'collisionStart', this.boundCollisionHandler)
}
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 && hero.velocity.y >= 0) {
this.hero.startJump()
}
}
/* Other code */
}
We declare a registerEvents()
method and call it from the constructor. This method indicates to the physics engine that we desire to hear about collision events in the physics world. We declare onCollisionStart()
as the collision event handler. When a collision happens, this method is called, and it extracts the Body
objects involved in the collision. We determine if one of these is the Hero
object using Array.find()
. Similarly, we determine if one of the colliding objects is a Platform
object. If the collision does indeed involve the hero and a platform, we check if the hero was falling, indicated by a non-negative y velocity. If so, we call the hero’s startJump()
method.
Now, try changing the platform’s x coordinates in the createPlatforms()
method of the Platforms
class to check that the jumping off a platform functionality is working correctly. Once satisfied with one platform, we may add another platform to the mix.
import { Platform } from "./Platform";
export class Platforms {
/* Other code */
createPlatforms() {
const numPlatforms = 2
for (let i = 0 ; i < numPlatforms ; i++) {
const x = 130
const y = 450
this.platforms.push(this.createPlatform(x, y - 100 * i ))
}
}
}
We create the two platforms slightly displaced vertically, so that we can see the hero jump off the first, and then off the second. If that is working, then we are ready to add more platforms, and implement camera follow so that the hero doesn’t disappear from view when it goes above the top edge of the screen.
Implementing camera follow
Let’s implement camera follow. We’ll make everything scroll down a little bit every time the hero approaches the top quarter of the screen.
/* Other code */
export class GameScene extends Scene {
/* Other code */
update(dt) {
super.update(dt)
this.hero.update(dt)
this.platforms.update(dt)
if (this.hero.sprite.y < App.config.screen.height / 4) {
App.physics.world.bodies.forEach( body => {
Matter.Body.translate(body, {x: 0, y: -this.hero.body.velocity.y / dt.deltaTime})
})
}
}
}
In the GameScene
class update()
method, we check if the hero has reached the top quarter of the screen. If so, we iterate over all the Body
objects in the physics engine and have them scroll down a bit using the translate()
method. Note that since we just want a scroll down, we have set the x value to 0. But that only moves the Body
objects in the physics engine, and not on the screen. To achieve that, we call the update() methods in the Hero
and Platforms
classes. All that we need to do in these methods is to synchronize the sprite’s position with that of the body. We modify the Platforms class as follows.
import { Platform } from "./Platform";
export class Platforms {
/* Other code */
update(dt) {
this.platforms.forEach(platform => platform.update(dt))
}
}
We just invoke the update()
method of the Platform
class. We implement that method as follows.
/* Imports */
export class Platform {
/* Other code */
update(dt) {
this.sprite.y = this.body.position.y
}
}
The Hero
class already has an update()
method that does the needful. So, now, when you run the game, you should see everything scroll down a bit once the hero jumps off the second platform. We can add a third platform to the mix and test.
import { Platform } from "./Platform";
export class Platforms {
/* Other code */
createPlatforms() {
const numPlatforms = 3
for (let i = 0 ; i < numPlatforms ; i++) {
const x = 130
const y = 450
this.platforms.push(this.createPlatform(x, y-100*i ))
}
}
}
This should still work. Great!
Enabling right or left movement
The hero needs to be able to move left or right. Let’s implement that. For key handling, we use the code from this article. We put that code in the src/scripts/system/Tools.js
file.
export class Tools {
static importAll(r) {
return r.keys().map(key => r(key))
}
static tap(handler) {
app.stage.on("tap", handler);
}
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;
}
}
To use the above utility code, we hook up the hero in GameScene to keyboard event handlers as follows.
/* Other mports */
import { Tools } from "../system/Tools";
export class GameScene extends Scene {
/* Other code */
async createHero(x, y) {
this.hero = new Hero(x, y )
await this.hero.createSprite()
this.hero.sprite.zIndex = 10
this.hero.createBody()
const right = Tools.keyboard('ArrowRight')
right.press = this.hero.moveRight.bind(this.hero)
right.release = this.hero.straighten.bind(this.hero)
const left = Tools.keyboard('ArrowLeft')
left.press = this.hero.moveLeft.bind(this.hero)
left.release = this.hero.straighten.bind(this.hero)
this.container.addChild(this.hero.sprite)
this.hero.startJump()
}
/* Other code */
}
After creating the hero’s body, we define an object name right
and an object named left
, for the right and left arrow keys, respectively. We bind methods named moveRight
, and moveLeft
in the Hero
class to the press
method of the keys, respectively. What this means, for instance, is when you press the right arrow key, the moveRight
method in the Hero
class is called. We similarly hook up methods named straighten
to the release
methods of the keys. What we’re trying to achieve is that as long as you hold down an arrow key, the hero keeps moving in that direction, until you release the key. To accomplish that, we implement the moveRight()
, moveLeft()
, and straighten()
methods in class Hero
as follows.
import * as PIXI from 'pixi.js'
import {App} from '../system/App.js'
import * as Matter from 'matter-js'
import { MatterUtil } from './MatterUtil.js'
export class Hero {
constructor(x, y) {
this.x = x
this.y = y
this.dx = 0
this.vy = App.config.hero.velocity
}
/* Other code */
update(dt) {
Matter.Body.setVelocity(this.body, {x: this.dx, y:this.body.velocity.y})
this.sprite.x = this.body.position.x - this.sprite.width / 2
this.sprite.y = this.body.position.y - this.sprite.height / 2
}
moveRight() {
this.dx = App.config.hero.velocityx
}
moveLeft() {
this.dx = -App.config.hero.velocityx
}
straighten() {
this.dx = 0
}
}
In the constructor, we initialize an attribute for the horizontal velocity to 0. In moveRight()
, we change it to a positive value defined in the Config
. In moveLeft()
, we change it to a negative value, and in straighten()
, we reset it to 0. In the update()
method, we use the physics engine to give the Body
a horizontal velocity using the dx
attribute using the setVelocity()
method. Now, we need to define the value in Config
.
/* Imports */
export const Config = {
/* Other code */
hero: {
velocity: 10,
velocityx: 5
}
}
Now, when you run the game, you should be able to move the hero around left or right.
Making the hero wrap around the screen
In the original Doodle Jump game, when the hero slides across one side of the screen, it re-emerges from the other side. Let’s implement that.
import * as PIXI from 'pixi.js'
import {App} from '../system/App.js'
import * as Matter from 'matter-js'
import { MatterUtil } from './MatterUtil.js'
export class Hero {
/* Other code */
update(dt) {
Matter.Body.setVelocity(this.body, {x: this.dx, y:this.body.velocity.y})
if(this.body.position.x >= App.config.screen.width + this.sprite.width/2) {
Matter.Body.setPosition(this.body, {x: 0, y:this.body.position.y})
}
if(this.body.position.x <= -this.sprite.width/2) {
Matter.Body.setPosition(this.body, {x: App.config.screen.width, y:this.body.position.y})
}
this.sprite.x = this.body.position.x - this.sprite.width / 2
this.sprite.y = this.body.position.y - this.sprite.height / 2
}
/* Other code */
}
We modify the update()
method to compare the x coordinates of the hero’s current position against both extremes. If the hero is at the right edge, we set the x coordinates of its position to 0. If, on the other hand, the hero is at the left edge, we set the x coordinates of its position to the right edge of the screen. Note that we are using the Body
object coordinates so there’s an adjustment by this.sprite.width / 2
involved.
Creating platforms randomly
Let’s now focus on creating platforms randomly. Let’s define a range of values for the randomness. That is, the minimum and maximum difference along the x axis between two consecutive platforms, and similar thresholds for the difference along the y axis.
/* Imports */
export const Config = {
/* Other code */
platforms: {
distance: {
y: {
min: 70,
max: 90
},
x: {
min: 40,
max: 120
}
}
}
}
With these thresholds defined, we modify the Platforms class.
/* Imports */
export class Platforms {
/* Other code */
createPlatforms() {
let x = 130
let y = 450
while(y >= App.config.platforms.distance.y.min) {
this.platforms.push(this.createPlatform(x, y))
y = y - (Math.random()*(App.config.platforms.distance.y.max - App.config.platforms.distance.y.min) + App.config.platforms.distance.y.min)
const xOffset = Math.random() * (App.config.platforms.distance.x.max - App.config.platforms.distance.x.min) + App.config.platforms.distance.x.min
if (x > App.config.screen.width / 2) {
x = Math.max(x - xOffset, 0)
}
else {
x = Math.min(x + xOffset, App.config.screen.width - 0.15 * this.platforms[0].sprite.width)
}
}
}
/* Other code */
}
Since the vertical distance between two consecutive platforms is randomly chosen, we don’t know exactly how many platforms there’ll be. So, we use a while
loop. We initialize variables x and y to represent the x and y coordinates of the top left of the current platform. The plan is to start populating platforms from near the bottom of the screen upwards. As long as there is enough room above the current platform, we enter the while
loop. In the while loop, we use the current values of x
and y
to create a new platform and add it to the list of platforms. We then draw a random value from the range of allowed minimum and maximum distance between two consecutive platforms along the y axis, and subtract that from the current value of y
. This gives us the y coordinate for the next platform. For the x coordinate, we do something slightly different. If the current platform is in the right half of the screen, we create the next platform a random offset away from it in the left half of the screen to keep things challenging for the hero. Since a random offset subtracted from the current platform’s x coordinate could result in a negative value, we pick the bigger of two values: this random adjusted value, and 0. We deal with the case of the current platform being in the left half of the screen similarly.
Now, if we run the program, we see a random number of platforms at random locations on the screen.
Creating more platforms on scroll
Once the hero has consumed all of the platforms currently on the screen, we need to create more platforms. Since the Platforms
class deals with creation and maintenance of the platforms, we’ll focus there.
/* Imports */
export class Platforms {
constructor(callback) {
this.platforms = []
this.addPlatformCallback = callback
this.createPlatforms()
}
/* Other code */
update(dt) {
this.platforms.forEach(platform => {
platform.update(dt)
})
const minY = Math.min(...this.platforms.map(platform => platform.sprite.y))
if (minY > App.config.platforms.distance.y.max) {
const y = Math.random()*(minY - App.config.platforms.distance.y.min)
const currentX = this.platforms[this.platforms.length - 1].sprite.x
let x = Math.random() * (App.config.platforms.distance.x.max - App.config.platforms.distance.x.min) + App.config.platforms.distance.x.min
if (currentX > App.config.screen.width / 2) {
x = currentX - x
}
else {
x = currentX + x
}
this.platforms.push(this.createPlatform(x, y))
this.addPlatformCallback(this.platforms[this.platforms.length - 1])
}
}
}
We modify the update()
method, since it is called periodically, and has the best opportunity to keep an eye on the platforms. We find the smallest y coordinate among all the platforms in the platforms
array and store it in the minY
variable. If the value of minY
exceeds the maximum allowable distance between two consecutive platforms, we’d best create another platform. So, we draw random x and y coordinates for the new platform, create a new platform using the createPlatform()
method, and append it to the platforms
array. This creates a Platform
object, but does not display it. The reason is that the code to display platforms is in the GameScene
class and relies on adding a platform’s Sprite
object to the GameScene
object’s Container
. For that purpose, we call a callback method addPlatformCallback()
with the latest platform as the argument. But where does this callback come from? We modify the constructor to accept that as argument, and store it in the instance variable. Since the Platforms
object is instantiated in the GameScene
class, we need to go and modify that instantiation to provide this callback, and implement that callback.
/* Other code */
export class GameScene extends Scene {
/* Other code */
createPlatforms() {
this.platforms = new Platforms(this.addPlatform.bind(this))
/* Other code */
}
addPlatform(platform) {
this.container.addChild(platform.sprite)
}
}
We define an addPlatform()
method to add the new platform’s Sprite
object to the Container
. We bind this method with the GameScene
object, and pass the bound reference as argument to the Platforms
constructor.
Now, if we run the game, as we hop off platforms, we see more platforms popping up above.
Destroying platforms that scroll out of view
Any platforms that disappear below the screen need to be destroyed. For that, we’ll need to modify the Platforms
class. Again, since the update()
method is called periodically, we’ll implement this logic there.
/* Imports */
export class Platforms {
/* Other code */
update(dt) {
/* Other code */
const gone = this.platforms.findIndex( platform => platform.sprite.y > App.config.screen.height)
if (gone !== -1) {
this.platforms[gone].destroy()
Matter.World.remove(App.physics.world, this.platforms[gone].body)
this.platforms.splice(gone, 1)
}
}
}
We find the index of any platform that has a y coordinate greater than the screen height. If there is none, the Array.findIndex()
method returns -1, and we don’t need to do anything. Otherwise, we remove the corresponding Platform
’s Body
object from the physics engine, so that it is no longer bothered with that object. We remove this platform from the platforms array using the Array.splice()
method, so that the Platforms class no longer updates its location. We also call a destroy()
method on the Platform object. This method can be implemented as follows.
/* Other code */
export class Platform {
/* Other code */
destroy() {
this.sprite.visible = false
}
}
We are merely making the sprite invisible in the destroy()
method, so that it is no longer displayed on the screen.
Score
In the original game, the score is determined by the height that the maximum height that the hero has reached so far. An important part of that statement is that if you reached a certain high platform, and then fell down to one lower than it, your score will not go down. Let’s implement a score strategy, and a means of displaying it.
We start with a simple strategy. Every time the hero jumps off a platform, we give 100 points. To keep track of the score, we define a class in /src/scripts/game/Stats.js
.
import { App } from "../system/App"
export class Stats {
constructor() {
if (!Stats.instance) {
this.reset()
Stats.instance = this
}
return Stats.instance
}
reset() {
this.score = 0
}
}
We use the singleton pattern to create only one instance of the class per game. It doesn’t make sense to have more than one instance of it. The constructor returns the only instance of the class. If, by mistake, we try to create another instance, we’d still get the reference to the sole instance of the class. In the reset()
method, we initialize a score
variable to 0.
Now, we use this class in GameScene
.
/* Other imports */
import { Stats } from "./Stats";
export class GameScene extends Scene {
create() {
/* Other code */
this.stats = new Stats()
}
/* 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)
if (hero && platform && hero.velocity.y >= 0) {
this.stats.score += 100
this.hero.startJump()
}
}
/* Other code */
}
We import the Stats
class and create an instance of it in the constructor. In the collision handler, when we detect the hero’s landing on a platform, we increment the score by 100. Now, on to displaying this score.
We define a heads-up-display (HUD) class in src/scripts/game/Hud.js
.
import * as PIXI from "pixi.js";
import { App } from '../system/App';
export class Hud {
constructor(score) {
this.score = score
this.createDisplay()
}
createDisplay() {
this.container = new PIXI.Container()
this.textStyle = new PIXI.TextStyle({
fontFamily: 'Arial', // Choose a font that includes the heart symbol
fontSize: 18,
fill: 'black' // Color of the heart symbol
});
this.score = new PIXI.Text({
text: `Score: ${this.score}`,
style: this.textStyle
})
this.container.addChild(this.score)
this.container.x = 20
this.container.y = 20
}
update(score) {
this.score.text = 'Score: ' + score
}
}
In the constructor, we initialize the score variable, and call the createDisplay()
method. In the createDisplay()
method, we create a Text
element with the score, put it in the Contiiner
for the class, set the container’s position relative to its parent at 20 pixels below and to the right from the top-left corner. We define an update()
method so that the GameScene
class can call it to refresh the displayed score. Now, we need to incorporate this in the GameScene
class.
/* Other imports */
import { Hud } from "./Hud";
export class GameScene extends Scene {
create() {
/* Other code */
this.createHud()
}
createHud() {
this.hud = new Hud(this.stats.livesRemaining, this.stats.score)
this.container.addChild(this.hud.container)
}
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 && hero.velocity.y >= 0) {
this.stats.score += 100
this.hud.update(this.stats.score)
this.hero.startJump()
}
}
}
We import the Hud
class. We create an instance of it in the constructor through the createHud()
method. The createHud()
method also adds the Hud
’s Container
object to the GameScene
’s Container
object, thereby displaying it near the top-left corner. In the collision handler, after updating the score
variable, we call the Hud
’s update()
method so that the score text is updated on the screen.
Now, every time the hero hops off a platform, it gets 100 points. But that’s not the behavior we want. For instance, if the hero just keeps jumping off the first platform long enough, it’ll get a high score. Let’s fix that. What we can do is to define a variable that holds the current platform pointer. If the hero jumps on to the same platform successively, we wouldn’t award a score.
/* Imports */
export class GameScene extends Scene {
create() {
this.stats = new Stats()
this.createBackground();
this.createHero(App.config.screen.width / 2, App.config.screen.height * 3 / 4)
this.createPlatforms()
this.registerEvents()
this.createHud()
this.currenPlatform = null
}
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 && hero.velocity.y >= 0) {
if (this.currenPlatform !== platform) {
this.currenPlatform = platform
this.stats.score += 100
this.hud.update(this.stats.score)
}
this.hero.startJump()
}
}
}
We initialize currentPlatform
to null
in the constructor since the hero is not on a platform in the beginning. In the collision detector, we check if the new platform
is the same as the currentPlatform
. If not, we award the score. Great! Now, hopping off the same platform wouldn’t award a score.
However, there’s still one problem. Going to a higher platform, then jumping back to an earlier platform still awards a score, which does not appear to be a good idea. How do we fix that? We could compare the current platform’s y coordinate with the y coordinate of the previous platform.
/* Imports */
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)
if (hero && platform && hero.velocity.y >= 0 && hero.gameHero.sprite.y <= platform.gamePlatform.sprite.y) {
if (!this.currenPlatform || (this.currenPlatform !== platform && this.currenPlatform.gamePlatform.sprite.y > platform.gamePlatform.sprite.y)) {
this.currenPlatform = platform
this.stats.score += 100
this.hud.update(this.stats.score)
}
this.hero.startJump()
}
}
}
The score increment needs to happen unconditionally, if the hero hasn’t yet landed on a platform. That’s why have !this.currentPlatform
as the first predicate in the if
statement. If that’s not the case, we need to check the y coordinate of the new platform against the previous one. Since we’re working with Body
objects in the collision handler, we use the gamePlatform
references to get to the Sprite objects to access the y coordinate. That fixes the scoring scheme. One other change we make is to check if the hero’s sprite strikes the platform from above. Otherwise, if the hero collides with any side of a platform while following, it jumps. That is not expected behavior.
Hero’s demise
Let’s implement restarting the player’s turn when the hero falls below the game screen. For that, first off, we implement the functionality in the Hero
class.
/* Other code */
export class Hero {
/* Other code */
update(dt) {
/* Other code */
if (this.sprite.y > App.config.screen.height) {
this.sprite.emit('die')
}
}
}
In the update()
method we check if the hero has fallen below the screen’s height. If so, we emit a “die” event from the Sprite
object. Let’s catch that event in the GameScene
class.
/* Imports */
export class GameScene extends Scene {
/* Other code */
async createHero(x, y) {
this.hero = new Hero(x, y )
await this.hero.createSprite()
this.hero.sprite.zIndex = 10
this.hero.createBody()
this.hero.sprite.once('die', () => {
this.stats.livesRemaining--
if (this.stats.livesRemaining > 0) {
App.scenes.start('Game')
}
})
/* Other code */
}
}
After creating the hero, we subscribe to the “die” event from its Sprite
. Upon receiving this message, we decrement, a livesRemaining
variable in the Stats class, and if it isn’t negative, we restart the game. Let’s define that livesRemaining
feature. First, we define the number of lives in the Config
.
/* Imports */
export const Config = {
/* Other code */
hero: {
velocity: 10,
velocityx: 5,
lives: 3
},
/* Other code */
}
We declare that the hero gets to have three lives. Now, to the Stats
class to implement the livesRemaining
variable.
import { App } from "../system/App"
export class Stats {
/* Constructor */
reset() {
this.livesRemaining = App.config.hero.lives
this.score = 0
}
}
In the reset()
method, we initialize the livesRemaining
variable from the Config
. Now, on to the Hud
class to display the lives remaining.
import * as PIXI from "pixi.js";
import { App } from '../system/App';
export class Hud {
constructor(lives, score) {
this.score = score
this.remainingLives = lives
this.createDisplay()
}
createDisplay() {
this.container = new PIXI.Container()
this.textStyle = new PIXI.TextStyle({
fontFamily: 'Arial', // Choose a font that includes the heart symbol
fontSize: 18,
fill: 'red' // Color of the heart symbol
});
// Create a new Text object with the heart symbol
let livesText = ''
for (let i = 0 ; i < this.remainingLives ; i++) {
livesText += '❤️'
}
this.lives = new PIXI.Text({text: livesText, style: this.textStyle});
this.score = new PIXI.Text({
text: `Score: ${this.score}`,
style: this.textStyle
})
this.score.style.fill = 'black'
this.container.addChild(this.score, this.lives)
this.container.x = 20
this.container.y = 20
this.lives.x = App.config.screen.width /2
}
update(score) {
this.score.text = 'Score: ' + score
}
}
We define the constructor to accept the lives remaining as well as the score as arguments. We initialize the livesRemaining
variable in the constructor. In the createHud()
method, we add another Text
element with red colored hearts equal in number to the value of livesRemaining
. We add this Text
element to the Container
along side the score. We position the top-left corner of the hearts at the horizontal center of the screen. With that, you should see the lives remaining on the screen, as well. If the hero dies, the number of hearts decreases.
One other thing we should do is to clean up the Body objects from the physics engine. Let’s implement that in the Platforms
class.
/* Imports */
export class Platforms {
/* Other code */
destroy() {
this.platforms.forEach(platform => {
Matter.World.remove(App.physics.world, platform.body)
platform.destroy()
})
}
}
We implement a destroy()
method that iterates over the Platform
objects and removes the corresponding Body
objects from the physics engine. We call this from the GameScene
class when restarting the game.
/* Imports */
export class GameScene extends Scene {
/* Other code */
async createHero(x, y) {
/* Other code */
this.hero.sprite.once('die', () => {
this.stats.livesRemaining--
if (this.stats.livesRemaining > 0) {
App.app.ticker.remove(this.update, this)
this.platforms.destroy()
Matter.World.remove(App.physics.world, this.hero.body)
this.unRegisterEvents()
App.scenes.start('Game')
}
})
/* Other code */
}
unRegisterEvents() {
Matter.Events.off(App.physics, 'collisionStart', this.boundCollisionHandler)
}
}
We remove the update()
method from the PIXI.js ticker. We call the destroy()
method of the Platforms class when restarting the game. We also remove the Hero
object from the physics engine, and deregister our collision event handler.
Game over
Finally, we need to implement a Game Over screen. We define a new class in src/scripts/game/GameOver.js
.
import { Scene } from '../system/Scene';
import {App} from '../system/App'
import * as PIXI from "pixi.js";
import { Stats } from "./Stats";
export class GameOver extends Scene {
create() {
this.stats = new Stats()
this.container = new PIXI.Container();
this.createBackground()
this.createTitle()
this.createButton()
}
createBackground() {
const gradientFill = new PIXI.FillGradient(0, 0, App.app.renderer.width, App.app.renderer.height);
const colorStops = [0x020024, 0x090979, 0x00d4ff];
colorStops.forEach((number, index) =>
{
const ratio = index / colorStops.length;
gradientFill.addColorStop(ratio, number);
});
this.background = new PIXI.Graphics().rect(0, 0, App.app.renderer.width, App.app.renderer.height).fill(gradientFill);
this.container.addChild(this.background);
}
createTitle() {
const textStyle = new PIXI.TextStyle(
{
fontFamily: 'Arial',
fontSize: 48,
fontStyle: 'italic',
fontWeight: 'bold',
fill: '#EEEEEE',
stroke: { color: '#4a1850', width: 5, join: 'round' },
dropShadow: {
color: '#000000',
blur: 4,
angle: Math.PI / 6,
distance: 6,
}})
this.titleText = new PIXI.Text({text: 'Game over',
style: textStyle
});
this.titleText.anchor.set(0.5); // Center the text
this.titleText.x = App.app.renderer.width / 2;
this.titleText.y = App.app.renderer.height / 4;
this.container.addChild(this.titleText);
textStyle.fontSize = 30
this.scoreText = new PIXI.Text({text: `You scored: ${this.stats.score}`,
style: textStyle
});
this.scoreText.anchor.set(0.5); // Center the text
this.scoreText.x = App.app.renderer.width / 2;
this.scoreText.y = App.app.renderer.height / 2;
this.container.addChild(this.scoreText);
}
createButton() {
this.buttonContainer = new PIXI.Container()
this.buttonContainer.interactive = true
this.buttonContainer.buttonMode = true
this.buttonContainer.on('pointerdown', this.onStartButtonClick.bind(this)); // Define the click event handler
this.buttonGraphics = new PIXI.Graphics().rect(0, 0, 200, 60).fill(0xffffff);
this.buttonContainer.addChild(this.buttonGraphics);
const buttonStyle = new PIXI.TextStyle({
fontFamily: 'Arial',
fontSize: 24,
fill: 0x0
})
const buttonText = new PIXI.Text({text: 'Start', style: buttonStyle});
buttonText.anchor.set(0.5); // Center the text
buttonText.x = this.buttonContainer.width / 2;
buttonText.y = this.buttonContainer.height / 2;
this.buttonContainer.addChild(buttonText);
this.buttonContainer.pivot.set(this.buttonContainer.width / 2, this.buttonContainer.height / 2);
this.buttonContainer.position.set(App.app.renderer.width / 2, App.app.renderer.height * 3 / 4);
this.container.addChild(this.buttonContainer)
this.buttonContainer.on('pointerover', () => {
this.buttonContainer.cursor = 'pointer';
});
// Add pointerout event listener to revert cursor to auto when leaving button
this.buttonContainer.on('pointerout', () => {
this.buttonContainer.cursor = 'auto';
});
}
onStartButtonClick() {
this.stats.reset()
App.scenes.start('Game')
}
update(dt) {
super.update(dt)
}
destroy() {
}
}
This class extends Scene
, just like GameScene
, so that we can have it show up through the App.scenes.start()
method. We create some text, and a button. We hook up the button to start a new game.
Next, we need to have the GameScene
class display the game over scene.
/* Imports */
export class GameScene extends Scene {
/* Other code */
async createHero(x, y) {
/* Other code */
this.hero.sprite.once('die', () => {
App.app.ticker.remove(this.update, this)
this.stats.livesRemaining--
this.platforms.destroy()
Matter.World.remove(App.physics.world, this.hero.body)
this.unRegisterEvents()
if (this.stats.livesRemaining > 0) {
App.scenes.start('Game')
}
else {
App.scenes.start('GameOver')
}
})
/* Other code */
}
}
Some of the steps like removing update()
from the ticker, and deregistering from the collision event need to happen whether the game is over or not. So, we move those lines of code outside the if
statement. If the number of lives remaining has become negative, we start the game over scene. We need to define this scene in the Config
class.
/* Imports */
export const Config = {
/* Other code */
scenes: {
"Game": GameScene,
"startScene": Game,
"GameOver": GameOver
},
/* Other code */
}
That’s it. Now, if the hero dies twice, you get the Game Over screen, with the option to start a new game.
That’s all folks!
This has been another long blog. The game wasn’t small, so that was expected. I hope you had success replicating it. Let me know if you encounter any error, have difficulty replicating any step, or would like more clarity on some steps. You may download the code and assets for this game from this repository.