Get the full commented source code of

HTML5 Suika Watermelon Game

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.