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 the 3rd part of the tutorial series about the creation of a “Stairs” HTML5 game using Phaser and Three.js.

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.

Now it’s time to make the ball move left and right, and also to add more spikes, to make the game more challenging.

As for ball movement, it’s quite simple at the moment: we detect mouse or input x coordinate and we move the ball to such coordinate, clamping it a bit to make the ball stay inside the platform.

It’s not the best way of moving the ball, it works.

As for the spikes, we are adding all spikes when each step is created, then randomly set some of them visible, picking them from an array.

Have a look at the result:

Move the ball with the mouse and look how spikes are generated.

This has been possible with one HTML file, one CSS file and five TypeScript files, let’s have a look at them:

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 : 100,

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

    // max spikes per step at the same time
    maxSpikesPerStep : 4
}

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 { 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 : [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';

// this class extends Scene class
export class PlayGame extends Phaser.Scene {

    // step to build the staircase
    steps : ThreeStep[];

    // the ball
    ball : ThreeBall;

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

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

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

    // 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  = new THREE.PerspectiveCamera(45, width / height, 1, 10000);
        camera.position.set(width / 2, 720, 640);
        camera.lookAt(width / 2 , 560, 320);

        // add an ambient light
        const ambientLight : THREE.AmbientLight = new THREE.AmbientLight(0xffffff, 1);
        threeScene.add(ambientLight);
        
        // add a directional light
        const directionalLight : THREE.DirectionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
        directionalLight.castShadow = true;
        directionalLight.position.set(270, 200, 0);
        directionalLight.target.position.set(270, 100, -1000);
        threeScene.add(directionalLight);
        threeScene.add(directionalLight.target)
       
        // add a spotlight
        const spotLight : THREE.SpotLight = new THREE.SpotLight(0xffffff, 0.2, 0, 0.4, 0.5, 0.1);
        spotLight.position.set(270, 1000, 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;
    }

    // method to be executed at each frame 
    update(time : number, deltaTime : number) : void {

        // update ball position
        this.ball.updateBallPosition(deltaTime, this.game.input.activePointer.x);

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

            // adjust step position according to speed, delta time and step size
            step.position.y -= deltaTime / 1000 * GameOptions.staircaseSpeed;
            step.position.z += deltaTime / 1000 * GameOptions.staircaseSpeed * GameOptions.stepSize.z / GameOptions.stepSize.y;
           
            // 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.showSpikes(GameOptions.maxSpikesPerStep);
            }
        })
    }
}

threeStep.ts

Custom class extending THREE.Group to define the step, which is made of nine Three meshes: one with a box geometry for the step itself, and eight with a cone geometry for the spikes.

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[];
   
    constructor(scene : Phaser.Scene, threeScene : THREE.Scene, stepNumber : number) {
        super();
        
        // 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
        });
        const step : THREE.Mesh = new THREE.Mesh(stepGeometry, stepMaterial);
        step.receiveShadow = true;
        this.add(step);

        // build the spike
        const spikeGeometry = new THREE.ConeGeometry(23, 40, 32);
        const spikeMaterial = new THREE.MeshStandardMaterial({
            color: 0x444444
        });

        // build the spikes and set them to invisible
        this.spikes = [];
        for (let i : number = - GameOptions.stepSize.x / 2 + 25; i <= GameOptions.stepSize.x / 2 - 25; i += 50) {
            let spike = new THREE.Mesh(spikeGeometry, spikeMaterial);
            spike.position.set(i, GameOptions.stepSize.y, 0);
            spike.visible = false;
            this.add(spike);
            this.spikes.push(spike);
        }

        // show some random spikes
        this.showSpikes(GameOptions.maxSpikesPerStep);
    
        // 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
    showSpikes(n : number) : void {

        // set all spikes invisible
        this.spikes.forEach((spike : THREE.Mesh) => {
            spike.visible = false;
        });

        // repeat this loop "n" times
        for (let i : number = 0; i < n; i ++) {

            // select a random spike and make it visible
            let spike : THREE.Mesh = Phaser.Utils.Array.GetRandom(this.spikes);
            spike.visible = true;
        }
    }
}

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;

    // time needed for the ball to jump over next step
    jumpTime : number;

    // ball starting y position
    startY : number;

    // ball horizontal movement range
    movementRange : 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);
        
        // ball casts shadows
        this.castShadow = true;

        // ball is in time for zero milliseconds at the moment
        this.ballTime = 0;

        // jump time, in seconds, is determined by y step size divided by staircase speed
        this.jumpTime = GameOptions.stepSize.y / GameOptions.staircaseSpeed * 1000;

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

        // set movement range to let the ball always remain on a step
        this.movementRange = [this.position.x - GameOptions.stepSize.x / 2 + GameOptions.ballDiameter / 2, this.position.x + GameOptions.stepSize.x / 2 - GameOptions.ballDiameter / 2];

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

        // determine ball time, being sure it will never be greater than the time required to jump on next step
        this.ballTime = (this.ballTime += delta) % this.jumpTime;

        // ratio is the amount of time passed divided by the time required to jump on next step
        let ratio : number = this.ballTime / this.jumpTime;

        // set ball x position
        this.position.setX(Phaser.Math.Clamp(posX, this.movementRange[0], this.movementRange[1]));

        // set ball y position using a sine curve
        this.position.setY(this.startY + Math.sin(ratio * Math.PI) * GameOptions.jumpHeight);
    }
}

Moving the ball was quite easy, next step will be a bit harder, because we’ll need to check for collisions between ball and spikes without using any physics engine. Meanwhile, download the source code of this example and start experimenting.

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