Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Stairs game, 3D, Game development, HTML5, Javascript, Phaser and TypeScript.

Welcome to 2023! We are going to start the new year in a great way, with a lot of improvements to my Stairs prototype built with Phaser.

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.

In this new step we are going to add a lot of improvements, and the most important is the capability of using the latest Three version.

I had to use an old Three version due to an incompatibility as you can see in my Twitter’s posts #1#2 and #3, but I finally managed to solve the issue by creating a dummy mesh which is not useful to the game but simply glitches leaving the rest of the meshes intact.

I also added moving steps, using trigonometry as for the ball bounce, and added bonuses to collect.

Let’s see the final result:

Click and drag to move the ball, avoid the spikes, collect the bonuses and watch out for moving steps.

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.

// 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 : 70,

    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,

    // max visible spikes per step at the same time
    maxVisibleSpikesPerStep : 4,

    // step colors
    stepPalette : [0x4deeea, 0x74ee15, 0xffe700, 0xf000ff, 0x001eff],

    // amount of steps needed before level changes
    levelChange : 10
}

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;

    // game object to display score text
    scoreText : Phaser.GameObjects.BitmapText;

    constructor() {
        super({
            key: 'PlayGame'
        });
    }

    // method to be executed when the scene has been created
    create() : void {

        // 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 {
    
        // 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
            }

            // 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) {

                // get step position
                let stepPosition : number = this.steps[this.ball.stepIndex].position.x;

                // is the ball outside the step?
                if (this.ball.position.x < stepPosition - GameOptions.stepSize.x / 2 || this.ball.position.x > stepPosition + GameOptions.stepSize.x / 2) {
                    
                    // it's game over...
                    this.gameState = GameState.Over;

                    // 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());
                
            }

            // loop through steps array
            this.steps.forEach((step : ThreeStep) => {

                // update step position
                step.updateStepPosition(deltaTime, this.speed);
            
                // if the step is leaving the game from the bottom...
                if (step.position.y < - 40) {

                    // ...place it on top of the staircase
                    step.position.y += GameOptions.stepsAmount * GameOptions.stepSize.y;
                    step.position.z -= GameOptions.stepsAmount * GameOptions.stepSize.z;

                    // randomly show step spikes
                    step.initializeStep(GameOptions.maxVisibleSpikesPerStep, GameOptions.stepsAmount + this.climbedSteps - GameOptions.ballStartingStep + 1, true);
                }
            })

            // initialize a 3D vector to store bonus position
            let bonusPosition : THREE.Vector3 = new THREE.Vector3;

            // get bonus item world position
            this.steps[this.ball.stepIndex].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));

            // if the bonus ivisible and distance is less than the sum of ball and bonus radii...
            if (this.steps[this.ball.stepIndex].bonus.visible  && ballDistance < GameOptions.ballDiameter / 2 + GameOptions.ballDiameter / 4) {

                // 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) {

                        spike.geometry.computeBoundingBox()
                        const box = new THREE.Box3();
                        box.copy( spike.geometry.boundingBox as THREE.Box3).applyMatrix4( spike.matrixWorld );
                        
                        // it's game over!
                        this.gameState = GameState.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;
            }      
        }

        // 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 tenThree 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;

    // ths bonus
    bonus : THREE.Mesh;
   
    constructor(scene : Phaser.Scene, threeScene : THREE.Scene, stepNumber : number) {
        super();

        // set mainScene to current scene
        this.mainScene = scene;

        // I set movement time to 2000, but it's up to you
        this.movementTime = 2000;

        // 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: 0x444444
        });

        // 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);

        // initialize the step
        this.initializeStep(GameOptions.maxVisibleSpikesPerStep, stepNumber, false);
    
        // position the group properly
        this.position.set(scene.game.config.width as number / 2, stepNumber * GameOptions.stepSize.y, stepNumber * -GameOptions.stepSize.z);

        // add the group to the scene
        threeScene.add(this);
    }

    // method to show a random amount of spikes
    initializeStep(spikesAmount : number, stepNumber : number, moving : boolean) : void {

        // is the step moving?
        this.isMoving = moving;

        // 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)
    
        // is the step moving?
        if (moving) {

            // assign a random step time
            this.stepTime = Phaser.Math.FloatBetween(0, 2 * this.movementTime);
        }

        // set step color
        let color : number = Math.floor((stepNumber - GameOptions.ballStartingStep - 1) / GameOptions.levelChange) % 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 (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;
    }
}

threeBall.ts

Custom class extending THREE.Mesh to define the ball.

import { GameOptions } from './gameOptions';
import * as THREE from 'three';

// 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 : 0x444444
        });
        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;
    }

    // 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 now all the mechanics in the original gameplay have been added, it’s up to you to build something interesing about it. I will complete the prototype and launch the final game in a few days, download the source code and experiment.

Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.