Talking about Endless Runner game, Game development, HTML5, Javascript and Phaser.
Ok, before we start talking about this 5th step of the HTML5 endless runner prototype powered by Phaser and Arcade physics, let’s be honest: how may times did you swear at the game because of that stupid pixel perfect collision which just killed you?
We don’t want players to be frustrated by our game, the 1980s with impossible Commodore 64 games is over so we are going to be kind with players in a way I am about to explain you, but first things first, let me show you the series recap:
in first step, I was able to set up a basic endless runner with double jump and room for customization using object pooling to save resources.
In second step I added even more room for customization and – most important – a player animation.
In third step I added texture to platforms – no more white boxes – and coins to collect. Coins have their own animation and once collected they disappear in a way similar to the one you used to see in Super Mario series.
In step 4 I added a scrolling parallax background, using a series of images of mountains created with my script to programmatically draw mountains.
Now we are going to add deadly fire, but as we do not want to punish players that much, our hero will die only when touching the fire at its very center.
Look at the game:
Click or tap to jump or double jump. If you noticed, most times you just touch the fire you won’t die. You will die only if you completely run into the fire.
Want to know the trick? Look at this picture:
The purple lines which outline each sprite are the physics bounding boxes. Basically collisions are handled by checking if bounding boxes intersect. As you can see, the flame has a quite small bounding box, meaning only the center of the flame will trigger the collision.
Fire bounding box size is set at line 355:
let game;
// global game options
let gameOptions = {
// platform speed range, in pixels per second
platformSpeedRange: [300, 300],
// mountain speed, in pixels per second
mountainSpeed: 80,
// spawn range, how far should be the rightmost platform from the right edge
// before next platform spawns, in pixels
spawnRange: [80, 300],
// platform width range, in pixels
platformSizeRange: [90, 300],
// a height range between rightmost platform and next platform to be spawned
platformHeightRange: [-5, 5],
// a scale to be multiplied by platformHeightRange
platformHeighScale: 20,
// platform max and min height, as screen height ratio
platformVerticalLimit: [0.4, 0.8],
// player gravity
playerGravity: 900,
// player jump force
jumpForce: 400,
// player starting X position
playerStartPosition: 200,
// consecutive jumps allowed
jumps: 2,
// % of probability a coin appears on the platform
coinPercent: 25,
// % of probability a fire appears on the platform
firePercent: 25
}
window.onload = function() {
// object containing configuration options
let gameConfig = {
type: Phaser.AUTO,
width: 1334,
height: 750,
scene: [preloadGame, playGame],
backgroundColor: 0x0c88c7,
// physics settings
physics: {
default: "arcade"
}
}
game = new Phaser.Game(gameConfig);
window.focus();
resize();
window.addEventListener("resize", resize, false);
}
// preloadGame scene
class preloadGame extends Phaser.Scene{
constructor(){
super("PreloadGame");
}
preload(){
this.load.image("platform", "platform.png");
// player is a sprite sheet made by 24x48 pixels
this.load.spritesheet("player", "player.png", {
frameWidth: 24,
frameHeight: 48
});
// the coin is a sprite sheet made by 20x20 pixels
this.load.spritesheet("coin", "coin.png", {
frameWidth: 20,
frameHeight: 20
});
// the firecamp is a sprite sheet made by 32x58 pixels
this.load.spritesheet("fire", "fire.png", {
frameWidth: 40,
frameHeight: 70
});
// mountains are a sprite sheet made by 512x512 pixels
this.load.spritesheet("mountain", "mountain.png", {
frameWidth: 512,
frameHeight: 512
});
}
create(){
// setting player animation
this.anims.create({
key: "run",
frames: this.anims.generateFrameNumbers("player", {
start: 0,
end: 1
}),
frameRate: 8,
repeat: -1
});
// setting coin animation
this.anims.create({
key: "rotate",
frames: this.anims.generateFrameNumbers("coin", {
start: 0,
end: 5
}),
frameRate: 15,
yoyo: true,
repeat: -1
});
// setting fire animation
this.anims.create({
key: "burn",
frames: this.anims.generateFrameNumbers("fire", {
start: 0,
end: 4
}),
frameRate: 15,
repeat: -1
});
this.scene.start("PlayGame");
}
}
// playGame scene
class playGame extends Phaser.Scene{
constructor(){
super("PlayGame");
}
create(){
// group with all active mountains.
this.mountainGroup = this.add.group();
// group with all active platforms.
this.platformGroup = this.add.group({
// once a platform is removed, it's added to the pool
removeCallback: function(platform){
platform.scene.platformPool.add(platform)
}
});
// platform pool
this.platformPool = this.add.group({
// once a platform is removed from the pool, it's added to the active platforms group
removeCallback: function(platform){
platform.scene.platformGroup.add(platform)
}
});
// group with all active coins.
this.coinGroup = this.add.group({
// once a coin is removed, it's added to the pool
removeCallback: function(coin){
coin.scene.coinPool.add(coin)
}
});
// coin pool
this.coinPool = this.add.group({
// once a coin is removed from the pool, it's added to the active coins group
removeCallback: function(coin){
coin.scene.coinGroup.add(coin)
}
});
// group with all active firecamps.
this.fireGroup = this.add.group({
// once a firecamp is removed, it's added to the pool
removeCallback: function(fire){
fire.scene.firePool.add(fire)
}
});
// fire pool
this.firePool = this.add.group({
// once a fire is removed from the pool, it's added to the active fire group
removeCallback: function(fire){
fire.scene.fireGroup.add(fire)
}
});
// adding a mountain
this.addMountains()
// keeping track of added platforms
this.addedPlatforms = 0;
// number of consecutive jumps made by the player so far
this.playerJumps = 0;
// adding a platform to the game, the arguments are platform width, x position and y position
this.addPlatform(game.config.width, game.config.width / 2, game.config.height * gameOptions.platformVerticalLimit[1]);
// adding the player;
this.player = this.physics.add.sprite(gameOptions.playerStartPosition, game.config.height * 0.7, "player");
this.player.setGravityY(gameOptions.playerGravity);
this.player.setDepth(2);
// the player is not dying
this.dying = false;
// setting collisions between the player and the platform group
this.platformCollider = this.physics.add.collider(this.player, this.platformGroup, function(){
// play "run" animation if the player is on a platform
if(!this.player.anims.isPlaying){
this.player.anims.play("run");
}
}, null, this);
// setting collisions between the player and the coin group
this.physics.add.overlap(this.player, this.coinGroup, function(player, coin){
this.tweens.add({
targets: coin,
y: coin.y - 100,
alpha: 0,
duration: 800,
ease: "Cubic.easeOut",
callbackScope: this,
onComplete: function(){
this.coinGroup.killAndHide(coin);
this.coinGroup.remove(coin);
}
});
}, null, this);
// setting collisions between the player and the fire group
this.physics.add.overlap(this.player, this.fireGroup, function(player, fire){
this.dying = true;
this.player.anims.stop();
this.player.setFrame(2);
this.player.body.setVelocityY(-200);
this.physics.world.removeCollider(this.platformCollider);
}, null, this);
// checking for input
this.input.on("pointerdown", this.jump, this);
}
// adding mountains
addMountains(){
let rightmostMountain = this.getRightmostMountain();
if(rightmostMountain < game.config.width * 2){
let mountain = this.physics.add.sprite(rightmostMountain + Phaser.Math.Between(100, 350), game.config.height + Phaser.Math.Between(0, 100), "mountain");
mountain.setOrigin(0.5, 1);
mountain.body.setVelocityX(gameOptions.mountainSpeed * -1)
this.mountainGroup.add(mountain);
if(Phaser.Math.Between(0, 1)){
mountain.setDepth(1);
}
mountain.setFrame(Phaser.Math.Between(0, 3))
this.addMountains()
}
}
// getting rightmost mountain x position
getRightmostMountain(){
let rightmostMountain = -200;
this.mountainGroup.getChildren().forEach(function(mountain){
rightmostMountain = Math.max(rightmostMountain, mountain.x);
})
return rightmostMountain;
}
// the core of the script: platform are added from the pool or created on the fly
addPlatform(platformWidth, posX, posY){
this.addedPlatforms ++;
let platform;
if(this.platformPool.getLength()){
platform = this.platformPool.getFirst();
platform.x = posX;
platform.y = posY;
platform.active = true;
platform.visible = true;
this.platformPool.remove(platform);
let newRatio = platformWidth / platform.displayWidth;
platform.displayWidth = platformWidth;
platform.tileScaleX = 1 / platform.scaleX;
}
else{
platform = this.add.tileSprite(posX, posY, platformWidth, 32, "platform");
this.physics.add.existing(platform);
platform.body.setImmovable(true);
platform.body.setVelocityX(Phaser.Math.Between(gameOptions.platformSpeedRange[0], gameOptions.platformSpeedRange[1]) * -1);
platform.setDepth(2);
this.platformGroup.add(platform);
}
this.nextPlatformDistance = Phaser.Math.Between(gameOptions.spawnRange[0], gameOptions.spawnRange[1]);
// if this is not the starting platform...
if(this.addedPlatforms > 1){
// is there a coin over the platform?
if(Phaser.Math.Between(1, 100) <= gameOptions.coinPercent){
if(this.coinPool.getLength()){
let coin = this.coinPool.getFirst();
coin.x = posX;
coin.y = posY - 96;
coin.alpha = 1;
coin.active = true;
coin.visible = true;
this.coinPool.remove(coin);
}
else{
let coin = this.physics.add.sprite(posX, posY - 96, "coin");
coin.setImmovable(true);
coin.setVelocityX(platform.body.velocity.x);
coin.anims.play("rotate");
coin.setDepth(2);
this.coinGroup.add(coin);
}
}
// is there a fire over the platform?
if(Phaser.Math.Between(1, 100) <= gameOptions.firePercent){
if(this.firePool.getLength()){
let fire = this.firePool.getFirst();
fire.x = posX - platformWidth / 2 + Phaser.Math.Between(1, platformWidth);
fire.y = posY - 46;
fire.alpha = 1;
fire.active = true;
fire.visible = true;
this.firePool.remove(fire);
}
else{
let fire = this.physics.add.sprite(posX - platformWidth / 2 + Phaser.Math.Between(1, platformWidth), posY - 46, "fire");
fire.setImmovable(true);
fire.setVelocityX(platform.body.velocity.x);
fire.setSize(8, 2, true)
fire.anims.play("burn");
fire.setDepth(2);
this.fireGroup.add(fire);
}
}
}
}
// the player jumps when on the ground, or once in the air as long as there are jumps left and the first jump was on the ground
// and obviously if the player is not dying
jump(){
if((!this.dying) && (this.player.body.touching.down || (this.playerJumps > 0 && this.playerJumps < gameOptions.jumps))){
if(this.player.body.touching.down){
this.playerJumps = 0;
}
this.player.setVelocityY(gameOptions.jumpForce * -1);
this.playerJumps ++;
// stops animation
this.player.anims.stop();
}
}
update(){
// game over
if(this.player.y > game.config.height){
this.scene.start("PlayGame");
}
this.player.x = gameOptions.playerStartPosition;
// recycling platforms
let minDistance = game.config.width;
let rightmostPlatformHeight = 0;
this.platformGroup.getChildren().forEach(function(platform){
let platformDistance = game.config.width - platform.x - platform.displayWidth / 2;
if(platformDistance < minDistance){
minDistance = platformDistance;
rightmostPlatformHeight = platform.y;
}
if(platform.x < - platform.displayWidth / 2){
this.platformGroup.killAndHide(platform);
this.platformGroup.remove(platform);
}
}, this);
// recycling coins
this.coinGroup.getChildren().forEach(function(coin){
if(coin.x < - coin.displayWidth / 2){
this.coinGroup.killAndHide(coin);
this.coinGroup.remove(coin);
}
}, this);
// recycling fire
this.fireGroup.getChildren().forEach(function(fire){
if(fire.x < - fire.displayWidth / 2){
this.fireGroup.killAndHide(fire);
this.fireGroup.remove(fire);
}
}, this);
// recycling mountains
this.mountainGroup.getChildren().forEach(function(mountain){
if(mountain.x < - mountain.displayWidth){
let rightmostMountain = this.getRightmostMountain();
mountain.x = rightmostMountain + Phaser.Math.Between(100, 350);
mountain.y = game.config.height + Phaser.Math.Between(0, 100);
mountain.setFrame(Phaser.Math.Between(0, 3))
if(Phaser.Math.Between(0, 1)){
mountain.setDepth(1);
}
}
}, this);
// adding new platforms
if(minDistance > this.nextPlatformDistance){
let nextPlatformWidth = Phaser.Math.Between(gameOptions.platformSizeRange[0], gameOptions.platformSizeRange[1]);
let platformRandomHeight = gameOptions.platformHeighScale * Phaser.Math.Between(gameOptions.platformHeightRange[0], gameOptions.platformHeightRange[1]);
let nextPlatformGap = rightmostPlatformHeight + platformRandomHeight;
let minPlatformHeight = game.config.height * gameOptions.platformVerticalLimit[0];
let maxPlatformHeight = game.config.height * gameOptions.platformVerticalLimit[1];
let nextPlatformHeight = Phaser.Math.Clamp(nextPlatformGap, minPlatformHeight, maxPlatformHeight);
this.addPlatform(nextPlatformWidth, game.config.width + nextPlatformWidth / 2, nextPlatformHeight);
}
}
};
function resize(){
let canvas = document.querySelector("canvas");
let windowWidth = window.innerWidth;
let windowHeight = window.innerHeight;
let windowRatio = windowWidth / windowHeight;
let gameRatio = game.config.width / game.config.height;
if(windowRatio < gameRatio){
canvas.style.width = windowWidth + "px";
canvas.style.height = (windowWidth / gameRatio) + "px";
}
else{
canvas.style.width = (windowHeight * gameRatio) + "px";
canvas.style.height = windowHeight + "px";
}
}
Playing with bounding boxes will affect your gameplay: coins can be made easier to pick up if their bounding box are larger, enemies and obstacles can be easier to avoid if their bounding box are smaller. Do you like the idea, or do you prefer pixel perfect collisions? Download the source code and play with it.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.