Talking about Stairs game, 3D, Game development, HTML5, Javascript, Phaser and TypeScript.
Time to give the final touches to my Stairs prototype and publish the source code, before I add title screen, sounds and stuff to publish the complete game.
All posts in this tutorial series:
Step 1: Creation of an endless staircase with random spikes.
Step 2: Adding a bouncing ball without using any physics engine.
Step 3: Controlling the bouncing ball and adding more spikes.
Step 4: Collision detection without using physics, improved controls with virtual trackpad and level progression.
Step 5: Using latest Three.js version, moving steps, adding bonuses to collect and displaying score.
Step 6: Final touches and settings to automatically increase difficulty.
Being an endless runner game, we have to increase difficulty step by step, and I added this feature along with the capability of customizing it through simple variables.
Look at the game:
Click and drag to move the ball, avoid the spikes, collect the bonuses and watch out for moving steps.
Unlike previous versions, this time the game starts easy and gets harder when players keep climbing.
I also added another feature, a moved step. Non a moving step, which was already built, but a static step which is moved a bit to the left or to the right. This gives the staircase a better taste of randomness.
Let’s have a look at the commented source code: we have one HTML file, one CSS file and five TypeScript files.
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="style.css">
<script src="main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
style.css
The cascading style sheets of the main web page.
* {
padding : 0;
margin : 0;
}
body {
background-color: #011025;
}
canvas {
touch-action : none;
-ms-touch-action : none;
}
gameOptions.ts
Configurable game options. It’s a good practice to place all configurable game options, if possible, in a single and separate file, for a quick tuning of the game. A lot of options have been added to this script since previous step.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions = {
// amount of steps to be created and recycled
stepsAmount : 12,
// staircase speed, in pixels per second
staircaseSpeed : 60,
// staircase speed increase at each level
staircaseSpeedIncrease : 2,
// step size: x, y, z
stepSize : new Phaser.Math.Vector3(400, 40, 160),
// ball diameter, in pixels
ballDiameter : 60,
// ball starting step
ballStartingStep : 2,
// ball jump height, in pixels
jumpHeight : 150,
// amount of spikes per step
spikesPerStep : 7,
// margin between spikes, in pixels
spikesMargin : 5,
// step colors
stepPalette : [0x1ABC9C, 0x16A085, 0x2ECC71, 0x27AE60, 0x3498DB, 0x2980B9, 0x9B59B6, 0x8E44AD, 0x34495E, 0x2C3E50, 0xF1C40F, 0xF39C12, 0xE67E22, 0xD35400, 0xE74C3C, 0xC0392B, 0xECF0F1, 0xBDC3C7, 0x95A5A6, 0x7F8C8D],
// amount of steps needed before level changes
levelChange : 20,
// moving steps ratio
movingSteps : 0.2,
// moving steps ratio increase at each level
movingIncrease : 0.05,
// minimum moving time
minMovingTime : 2000,
// maximum moving time
maxMovingTime : 3000,
// moving time decrease per level
movingTimeDecreasePerLevel : 10,
// spikes per level
spikesPerLevel : 0.5,
// moved steps ratio
movedSteps : 0.2,
// moved steps ratio increase at each level
movedStepsIncrease : 0.05,
// offset to move the steps
movedStepOffset : 100
}
main.ts
This is where the game is created, with all Phaser related options.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame } from './playGame';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 540,
height : 960
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x011025,
scale : scaleObject,
scene : [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
playGame.ts
Main game file, all game logic and Three integration is stored here.
// THE GAME ITSELF
import * as THREE from 'three';
import { GameOptions } from './gameOptions';
import { ThreeStep } from './threeStep';
import { ThreeBall } from './threeBall';
// enum to represent the game states
enum GameState {
Waiting,
Playing,
Over
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
// step to build the staircase
steps : ThreeStep[];
// the ball
ball : ThreeBall;
// current game state
gameState : GameState;
// virtual pad x position
virtualPadX : number;
// variable to save ball x position when we start acting on the trackpad
ballPositionX : number;
// amount of climbed steps
climbedSteps : number;
// ball speed
speed : number;
// game score
score : number;
level : number;
// game object to display score text
scoreText : Phaser.GameObjects.BitmapText;
movingStepRatio : number;
constructor() {
super({
key: 'PlayGame'
});
}
// method to be executed when the scene has been created
create() : void {
Phaser.Utils.Array.Shuffle(GameOptions.stepPalette);
this.level = 0;
this.movingStepRatio = GameOptions.movingSteps;
// at the beginning of the game, set the score to zero
this.score = 0;
// add score text
this.scoreText = this.add.bitmapText(10, 10, 'font', '0');
// set the speed to default staircase speed
this.speed = GameOptions.staircaseSpeed;
// we start with climbedSteps = -1 because we are one step away from the first actual step
this.climbedSteps = -1;
// when virtual pad X is equal to -1, it means we aren't using the virtual pad
this.virtualPadX = -1;
// current game starte is: waiting (for player input)
this.gameState = GameState.Waiting;
// creation of the 3D scene
const threeScene : THREE.Scene = this.create3DWorld();
// add steps to the 3D scene and into steps array
this.steps = [];
for (let i = 0; i < GameOptions.stepsAmount; i ++) {
this.steps.push(new ThreeStep(this, threeScene, i));
}
// add the ball
this.ball = new ThreeBall(this, threeScene);
// this is a dummy mesh to prevent the incompatibility bug
// it's visible, but placed way under the stair case
// it must be the last THREE mesh added
const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( { color: 0x011025 } );
const mesh = new THREE.Mesh(geometry, material);
threeScene.add( mesh );
mesh.position.set(100, 120, -900)
// wait for input start
this.input.on('pointerdown', this.startControlling, this);
// wait for input end
this.input.on('pointerup', this.stopControlling, this);
}
// method to build the 3D scene
create3DWorld() : THREE.Scene {
// variables to store canvas width and height
const width : number = this.game.config.width as number;
const height : number = this.game.config.height as number;
// create a new THREE scene
const threeScene : THREE.Scene = new THREE.Scene();
// create the renderer
const renderer : THREE.WebGLRenderer = new THREE.WebGLRenderer({
canvas: this.sys.game.canvas,
context: this.sys.game.context as WebGLRenderingContext,
antialias: true
});
renderer.autoClear = false;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// add a camera
const camera : THREE.PerspectiveCamera = new THREE.PerspectiveCamera(50, width / height, 1, 10000);
camera.position.set(width / 2, 500, 600);
camera.lookAt(width / 2, 180, -320);
// add an ambient light
const ambientLight : THREE.AmbientLight = new THREE.AmbientLight(0xffffff, 0.8);
threeScene.add(ambientLight);
// add a spotlight
const spotLight : THREE.SpotLight = new THREE.SpotLight(0xffffff, 0.6, 0, 0.4, 0.5, 0.1);
spotLight.position.set(270, 2000, 0);
spotLight.castShadow = true;
spotLight.shadow.mapSize.width = 1024;
spotLight.shadow.mapSize.height = 1024;
spotLight.shadow.camera.near = 1;
spotLight.shadow.camera.far = 10000;
spotLight.shadow.camera.fov = 80;
spotLight.target.position.set(270, 0, -320);
threeScene.add(spotLight);
threeScene.add(spotLight.target);
// add a fog effect
const fog : THREE.Fog = new THREE.Fog(0x011025, 900, 2000);
threeScene.fog = fog;
// create an Extern Phaser game object
const view : Phaser.GameObjects.Extern = this.add.extern();
// custom renderer
// next line is needed to avoid TypeScript errors
// @ts-expect-error
view.render = () => {
renderer.state.reset();
renderer.render(threeScene, camera);
};
return threeScene;
}
// start controlling the ball
startControlling(e : Phaser.Input.Pointer) : void {
// save input x position
this.virtualPadX = e.position.x;
// save current ball x position
this.ballPositionX = this.ball.position.x;
// if game state is "waiting" (for player input) ...
if (this.gameState == GameState.Waiting) {
// ... then start playing
this.gameState = GameState.Playing;
}
}
// stop controlling the ball
stopControlling() : void {
// set virtual pad x to its default value
this.virtualPadX = -1;
}
// restart the game
restartGame() : void {
// it's game over
this.gameState = GameState.Over;
// restart the game in a second
this.time.addEvent({
delay : 1000,
callback : () => {
// start "PlayGame" scene
this.scene.start('PlayGame');
},
callbackScope : this
});
}
// method to be executed at each frame
update(time : number, deltaTime : number) : void {
// if we are playing...
if (this.gameState == GameState.Playing) {
// determine next ball x position, which is current ball x position
let nextBallXPosition : number = this.ball.position.x;
// if we are moving the virtual pad...
if (this.virtualPadX != -1) {
// adjust next ball x position according to virtual pad movmement
nextBallXPosition = this.ballPositionX + this.game.input.activePointer.x - this.virtualPadX
}
// loop through steps array
this.steps.forEach((step : ThreeStep) => {
// update step position
step.updateStepPosition(deltaTime, this.speed);
});
// update ball position, and check if we just jumped
let justBounced : boolean = this.ball.updateBallPosition(deltaTime, nextBallXPosition, this.speed);
// get 3D ball coordinates
let ballCoordinates : Phaser.Math.Vector3 = new Phaser.Math.Vector3(this.ball.position.x, this.ball.position.y, this.ball.position.z);
// did we just bounce?
if (justBounced) {
if (!this.ball.isInsideStep(this.steps[this.ball.stepIndex])) {
// call restartGame method
this.restartGame();
}
// update ball step inded
this.ball.stepIndex = (this.ball.stepIndex + 1) % GameOptions.stepsAmount;
// one more climbed step
this.climbedSteps ++;
// update the score
this.score ++;
// display the score
this.scoreText.setText(this.score.toString());
}
if (this.ball.gotBonus(this.steps[this.ball.stepIndex].bonus)) {
// hide the bonus
this.steps[this.ball.stepIndex].bonus.visible = false;
// update the score
this.score +=2;
// display the score
this.scoreText.setText(this.score.toString())
}
// initialize a 3D vector to store spike position
let spikePosition : THREE.Vector3 = new THREE.Vector3;
// loop through all spikes in the step we are about to land on
this.steps[this.ball.stepIndex].spikes.forEach((spike : THREE.Mesh) => {
// is the spike visible?
if (spike.visible) {
// get spike world position
spike.getWorldPosition(spikePosition);
// determine the distance between ball center and spike
let ballDistance : number = ballCoordinates.distance(new Phaser.Math.Vector3(spikePosition.x, spikePosition.y + GameOptions.stepSize.y / 2, spikePosition.z));
// is the distance lower than ball radius? This would mean spike's tip is inside the ball
if (ballDistance < GameOptions.ballDiameter / 2) {
// it's game over!
this.restartGame();
}
}
})
// if we climbed enough steps to make level change...
if (justBounced && this.climbedSteps % GameOptions.levelChange == 0 && this.climbedSteps >= GameOptions.levelChange) {
// ...increase the speed accordingly
this.speed += GameOptions.staircaseSpeedIncrease;
this.movingStepRatio += GameOptions.movingIncrease;
this.level ++;
}
}
// if the game is over...
if (this.gameState == GameState.Over) {
// call ball's die method
this.ball.die()
}
}
}
threeStep.ts
Custom class extending THREE.Group to define the step, which is made of ten Three meshes: one with a box geometry for the step itself, eight with a cone geometry for the spikes and one with the bonus geometry, which is a dodecahedron.
import { GameOptions } from './gameOptions';
import * as THREE from 'three';
// class to define the step, extends THREE.Group
export class ThreeStep extends THREE.Group {
// array to contain all step spikes
spikes : THREE.Mesh[];
// the step mesh
step : THREE.Mesh;
// is the step moving?
isMoving : boolean;
// amount of time the step is in play, useful to determine its position
stepTime : number;
// step movement time
movementTime : number;
// will be used as reference to main scene
mainScene : Phaser.Scene;
stepNumber : number;
// the bonus
bonus : THREE.Mesh;
constructor(scene : Phaser.Scene, threeScene : THREE.Scene, stepNumber : number) {
super();
this.stepNumber = stepNumber;
// set mainScene to current scene
this.mainScene = scene;
// at the beginning, step time is zero
this.stepTime = 0;
// build the step
const stepGeometry : THREE.BoxGeometry = new THREE.BoxGeometry(GameOptions.stepSize.x, GameOptions.stepSize.y, GameOptions.stepSize.z);
const stepMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : 0x09c4fe
});
this.step = new THREE.Mesh(stepGeometry, stepMaterial);
this.step.receiveShadow = true;
this.add(this.step);
// determine spike radius
let spikeRadius : number = ((GameOptions.stepSize.x - (GameOptions.spikesPerStep + 1) * GameOptions.spikesMargin) / GameOptions.spikesPerStep) / 2;
// build the spike
const spikeGeometry : THREE.ConeGeometry = new THREE.ConeGeometry(spikeRadius, GameOptions.stepSize.y, 32);
const spikeMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color: 0x053d5c
});
// build the spikes and set them to invisible
this.spikes = [];
// execute this loop "spikesPerStep" times
for (let i : number = 0; i < GameOptions.spikesPerStep; i ++) {
// build the i-th spike
let spike : THREE.Mesh = new THREE.Mesh(spikeGeometry, spikeMaterial);
spike.position.set(i * (spikeRadius * 2 + GameOptions.spikesMargin) - GameOptions.stepSize.x / 2 + spikeRadius + GameOptions.spikesMargin, GameOptions.stepSize.y, 0);
this.add(spike);
this.spikes.push(spike);
spike.visible = false;
}
// build the bonus
const bonusGeometry : THREE.DodecahedronGeometry = new THREE.DodecahedronGeometry(GameOptions.ballDiameter / 4);
const bonusMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color: 0xffff00
});
this.bonus = new THREE.Mesh(bonusGeometry, bonusMaterial);
this.bonus.position.set(0, GameOptions.stepSize.y, 0)
this.bonus.castShadow = true;
this.bonus.visible = false;
this.add(this.bonus);
// position the group properly
this.position.set(scene.game.config.width as number / 2, stepNumber * GameOptions.stepSize.y, stepNumber * -GameOptions.stepSize.z);
// initialize the step
this.initializeStep();
// add the group to the scene
threeScene.add(this);
}
// method to show a random amount of spikes
initializeStep() : void {
// get level according to step number
let level : number = Math.floor((this.stepNumber - GameOptions.ballStartingStep - 1) / GameOptions.levelChange);
// maximum spikes allowed on the step, according to level
let maxSpikes : number = Phaser.Math.Clamp(Math.ceil(level * GameOptions.spikesPerLevel), 1, GameOptions.spikesPerStep - 1);
// actual number of spikes, ranging from 1 to maxSpiles
let spikesAmount : number = Phaser.Math.RND.between(1, maxSpikes);
// probability of the step to be moved
let movedProbability : number = (level > 0) ? Math.random() : 1;
// probability of the step to be moving
let movingProbability : number = (level > 0) ? Math.random() : 1;
// is the step moving?
this.isMoving = (GameOptions.movedSteps + level * GameOptions.movingIncrease > movingProbability);
// place the step in the middle of the canvas
this.position.setX(this.mainScene.game.config.width as number / 2);
// if the step should be moved...
if (GameOptions.movedSteps + level * GameOptions.movedStepsIncrease > movedProbability) {
// ... then move it!
this.position.x += Phaser.Math.Between(0, GameOptions.movedStepOffset) * Phaser.Math.RND.sign();
}
// an array to keep track of empty spaces on the step
let emptySpaces : number[] = Phaser.Utils.Array.NumberArray(0, GameOptions.spikesPerStep - 1) as number[];
// shuffle the array
Phaser.Utils.Array.Shuffle(emptySpaces)
// step is not moving by default
this.isMoving = false;
// is the step moving?
if (GameOptions.movingSteps + level * GameOptions.movingIncrease > movingProbability) {
// set isMoving to true
this.isMoving = true;
// set movement time according to level
this.movementTime = Phaser.Math.Between(GameOptions.minMovingTime - level * GameOptions.movingTimeDecreasePerLevel, GameOptions.maxMovingTime - level * GameOptions.movingTimeDecreasePerLevel);
// assign a random step time, which means a random position
this.stepTime = Phaser.Math.FloatBetween(0, 2 * this.movementTime);
}
// set step color
let color : number = level % GameOptions.stepPalette.length;
// set all spikes invisible
this.spikes.forEach((spike : THREE.Mesh) => {
spike.visible = false;
});
// is this step higher than ball starting step?
if (this.stepNumber > GameOptions.ballStartingStep) {
// repeat this loop spikesAmount times
for (let i : number = 0; i < spikesAmount; i ++) {
// turn a spike visible
this.spikes[emptySpaces[i]].visible = true;
}
// place the bonus in an empty space
let bonusSpot : number = emptySpaces[spikesAmount];
this.bonus.visible = true;
this.bonus.position.setX(this.spikes[bonusSpot].position.x);
// set step color
// @ts-expect-error
this.step.material.color.setHex(GameOptions.stepPalette[color]);
}
}
// method to update step position
updateStepPosition(delta : number, speed : number) : void {
// determine step time
this.stepTime = (this.stepTime += delta) % (2 * this.movementTime);
// rotate a bit the bonus
this.bonus.rotation.x += delta * 0.001;
// get the ratio between step time and movement time
let ratio : number = this.stepTime / this.movementTime - 1;
// is the step moving?
if (this.isMoving) {
// move the step accordingly
this.position.setX(this.mainScene.game.config.width as number / 2 + Math.sin(ratio * Math.PI) * 80);
}
// adjust step position according to speed, delta time and step size
this.position.y -= delta / 1000 * speed;
this.position.z += delta / 1000 * speed * GameOptions.stepSize.z / GameOptions.stepSize.y;
// if the step is leaving the game from the bottom...
if (this.position.y < - 40) {
// ...place it on top of the staircase
this.position.y += GameOptions.stepsAmount * GameOptions.stepSize.y;
this.position.z -= GameOptions.stepsAmount * GameOptions.stepSize.z;
// increase step number
this.stepNumber += GameOptions.stepsAmount;
// randomly show step spikes
this.initializeStep();
}
}
}
threeBall.ts
Custom class extending THREE.Mesh to define the ball.
import { GameOptions } from './gameOptions';
import * as THREE from 'three';
import { ThreeStep } from './threeStep';
// class to define the ball, extends THREE.Mesh
export class ThreeBall extends THREE.Mesh {
// amount of time the ball is in play, useful to determine its position
ballTime : number;
// ball starting y position
startY : number;
// index of the step the ball is on
stepIndex : number
constructor(scene : Phaser.Scene, threeScene : THREE.Scene) {
// build the ball
const SphereGeometry : THREE.SphereGeometry = new THREE.SphereGeometry(GameOptions.ballDiameter / 2, 32, 32);
const sphereMaterial : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : 0x053d5c
});
super(SphereGeometry, sphereMaterial);
// step index is equal to ball starting step at the moment
this.stepIndex = GameOptions.ballStartingStep;
// ball casts shadows
this.castShadow = true;
// ball is in time for zero milliseconds at the moment
this.ballTime = 0;
// determine ball starting y position according to step size, ball diameter, and ball starting step
this.startY = (GameOptions.ballStartingStep + 0.5) * GameOptions.stepSize.y + GameOptions.ballDiameter / 2
// position the ball properly
this.position.set(scene.game.config.width as number / 2, this.startY , GameOptions.ballStartingStep * -GameOptions.stepSize.z);
// add the group to the scene
threeScene.add(this);
}
// method to update ball position according to the time the ball is in play
updateBallPosition(delta : number, posX : number, speed : number) : boolean {
// jump time, in seconds, is determined by y step size divided by staircase speed
let jumpTime : number = GameOptions.stepSize.y / speed * 1000;
// determine ball time, being sure it will never be greater than the time required to jump on next step
this.ballTime = (this.ballTime += delta) % jumpTime;
// ratio is the amount of time passed divided by the time required to jump on next step
let ratio : number = this.ballTime / jumpTime;
// set ball x position
this.position.setX(posX);
// set ball y position using a sine curve
this.position.setY(this.startY + Math.sin(ratio * Math.PI) * GameOptions.jumpHeight);
// if ballTime is less than or equal to deta time, it means the ball just bounced on a step
return this.ballTime <= delta;
}
isInsideStep(step : ThreeStep) : boolean {
// get step position
let stepPosition : number = step.position.x;
return this.position.x >= stepPosition - GameOptions.stepSize.x / 2 && this.position.x <= stepPosition + GameOptions.stepSize.x / 2;
}
gotBonus(bonus : THREE.Mesh) : boolean {
let ballCoordinates : Phaser.Math.Vector3 = new Phaser.Math.Vector3(this.position.x, this.position.y, this.position.z);
// initialize a 3D vector to store bonus position
let bonusPosition : THREE.Vector3 = new THREE.Vector3;
// get bonus item world position
bonus.getWorldPosition(bonusPosition);
// get the distance between the ball and the bonus
let ballDistance : number = ballCoordinates.distance(new Phaser.Math.Vector3(bonusPosition.x, bonusPosition.y, bonusPosition.z));
// ball got bonus if the bonus is visible and distance is less than the sum of ball and bonus radii
return bonus.visible && ballDistance < GameOptions.ballDiameter / 2 + GameOptions.ballDiameter / 4;
}
// method to make the ball die
die() : void {
// get ball material
let material : THREE.MeshStandardMaterial = this.material as THREE.MeshStandardMaterial;
// slowly turn ball material to red
material.color.lerp(new THREE.Color(0xff0000), 0.01);
}
}
And finally we can have our configurable game ready to play. Would you add some extra features? Download the source code.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.