Do you like my tutorials?

Then consider supporting me on Ko-fi

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

This is the result of an experiment I built while I was about to port the first step of Godot “Stairs” prototype in Phaser using Three.js to render 3D objects moving according to Phaser sprites.

I already built something similar when I added 3D to my Bouncy Light prototype, but:

1 – The prototype was more than 2 years old, and meanwhile both Phaser and Three have been updated several times.

2 – It was not written in TypeScript

3 – It used a Phaser3D extension which is for Phaser supporters only (like I am) full of unnecessary code, since my prototype only uses some basic features.

So this is basically a modern rewrite of 3D Bouncy Light prototype.

But why did I build this, while writing the porting of Stairs? Because I found an infamous bug which does not allow to properly render Phaser and Three.js together if you add a visible Phaser Game Object and more than one Three.js geometry.

There isn’t that much to say, just follow my Twitter’s post #1, #2 and #3 to see the problem I was facing and how I tried to solve them. And, why not? Follow me on Twitter if you didn’t already.

I found the latest Three.js version working properly with latest Phaser version is Three.js v 0.109 which is three years old but at least it works without workarounds.

So just to test if everything would be ok I built this minigame:

Just click or tap and hold to make ball move, release to stop it.

The actual game is the one you can see in the top of the canvas, the 3D is just a representation of what’s happening in the 2D environment.

Basically I coded the game logic with Phaser, and rendered it with Three.js

All this stuff in one HTML file, one CSS file and six 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 = {

    // player size
    playerSize : 20,

    // player gravity
    playerGravity : 1800,

    // player start x position, 0 = left; 1 = right
    playerStartXPosition : 0.2,
 
    // bounce velocity when the player hits a platform
    bounceVelocity : 900,
 
    // amount of platforms to be created and recycled
    platformAmount : 10,
 
    // platform speed, in pixels per second
    platformSpeed : 1000,
 
    // min and max distance range between platforms
    platformDistanceRange : [200, 350],
 
    // min and max platform height, , 0 = top of the screen; 1 = bottom of the screen
    platformHeightRange : [0.5, 0.8],
 
    // min and max platform length
    platformLengthRange : [40, 100],

    // platform thickness, in pixels (y size)
    platformThickness : 20
}

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 : 800,
    height : 600
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
    type : Phaser.WEBGL,
    transparent: true,
    backgroundColor : 0x011025,
    scale : scaleObject,
    scene : [PreloadAssets, PlayGame],
    physics : {
        default : 'arcade',
    }
}

// the game itself
new Phaser.Game(configObject);

preloadAssets.ts

Here we preload all assets to be used in the game, such as the sprites used for the player and the wall.

// CLASS TO PRELOAD ASSETS

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

    // constructor    
    constructor() {
        super({
            key : 'PreloadAssets'
        });
    }

    // method to be execute during class preloading
    preload() : void {

        // this is how we preload an image
        this.load.image('player', 'assets/player.png');
        this.load.image('platform', 'assets/platform.png');
	}

    // method to be called once the instance has been created
	create() : void {

        // call PlayGame class
        this.scene.start('PlayGame');
	}
}

playGame.ts

Main game file, all game logic and Three integration is stored here.

// THE GAME ITSELF

import * as THREE from 'three';
import { ThreePlayer } from './threePlayer';
import { GameOptions } from './gameOptions';
import { ThreePlatform } from './threePlatform';

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

    player : ThreePlayer;

    platformGroup : Phaser.Physics.Arcade.Group;

    threeScene : THREE.Scene;

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

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

        this.create3DWorld();

        this.player = new ThreePlayer(this, this.threeScene);

        this.addPlatforms();
      
        this.input.on('pointerdown', this.pointerDown, this);
        this.input.on('pointerup', this.pointerUp, this);

        this.cameras.main.setViewport(0, 0, 0, 0);
        this.cameras.main.setZoom(0.25);
    }

    pointerDown() : void {
        this.platformGroup.setVelocityX(-GameOptions.platformSpeed);
    }

    pointerUp() : void {
        this.platformGroup.setVelocityX(0);    
    }

    create3DWorld() : void {

        const width : number = this.game.config.width as number;
        const height : number = this.game.config.height as number;
       
        this.threeScene = new THREE.Scene();

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

        const ambientLight : THREE.AmbientLight = new THREE.AmbientLight(0xffffff, 1);
        this.threeScene.add(ambientLight);

        const spotLight : THREE.SpotLight = new THREE.SpotLight(0xffffff, 1, 0, 0.4, 0.5, 0.1);
        spotLight.position.set(width * GameOptions.playerStartXPosition, height * 3, 0);
        spotLight.castShadow = true;
        spotLight.shadow.mapSize.width = 512;
        spotLight.shadow.mapSize.height = 512;
        spotLight.shadow.camera.near = 1;
        spotLight.shadow.camera.far = 10000;
        this.threeScene.add(spotLight);
       
        const view : Phaser.GameObjects.Extern = this.add.extern();

        // @ts-expect-error
        view.render = () => {
            renderer.state.reset();
            renderer.render(this.threeScene, camera);
        };  
    }

    addPlatforms() : void {
        
        // creation of a physics group containing all platforms
        this.platformGroup = this.physics.add.group();
 
        // let's proceed with the creation
        for (let i = 0; i < GameOptions.platformAmount; i ++) {
            new ThreePlatform(this, this.platformGroup, this.threeScene);
        }
    }

    // method to be executed at each frame
    update() : void {
 
        // collision management ball Vs platforms
        this.physics.world.collide(this.platformGroup, this.player, () => {
            
            // bounce back the ball
            this.player.body.velocity.y = -GameOptions.bounceVelocity;
        }, undefined, this);
 
        // if 2D ball falls down the screen...
        if (this.player.y > this.game.config.height) {
 
            // restart the game
            this.scene.start("PlayGame");
        } 
    }
}

threePlayer.ts

Custom class extending Phaser.Physics.Arcade.Sprite to define the player, which is both an Arcade Sprite and a Three Mesh.

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

// this class extends Phaser.Physics.Arcade.Sprite
export class ThreePlayer extends Phaser.Physics.Arcade.Sprite {

    threeObject : THREE.Mesh;

    scene : Phaser.Scene; 

    constructor(scene : Phaser.Scene, threeScene : THREE.Scene) {
        super(scene, 0, 0, 'player');
        this.x = scene.game.config.width as number * GameOptions.playerStartXPosition;;
        this.displayWidth = GameOptions.playerSize;
        this.displayHeight = GameOptions.playerSize;
        scene.add.existing(this);
        scene.physics.add.existing(this);
        this.body.gravity.y = GameOptions.playerGravity;
        this.body.checkCollision.down = true;
        this.body.checkCollision.up = false;
        this.body.checkCollision.left = false;
        this.body.checkCollision.right = false;
        const geometry : THREE.BoxGeometry = new THREE.BoxGeometry(10, 10, 10);
        const material : THREE.Material = new THREE.MeshStandardMaterial({
            color : 0xff0000
        });
        this.threeObject = new THREE.Mesh(geometry, material);
        this.threeObject.scale.x = this.displayWidth / 10;
        this.threeObject.scale.y = this.displayHeight / 10;
        this.threeObject.scale.z = this.displayHeight / 10;
        this.threeObject.position.x = this.x;
        this.threeObject.castShadow = true;
        threeScene.add(this.threeObject);
        this.scene = scene;
    }

    preUpdate () : void {
        this.threeObject.position.y = this.scene.game.config.height as number - this.y;
    }
}

threePlatform.ts

Custom class extending Phaser.Physics.Arcade.Sprite to define the platform, which is both an Arcade Sprite and a Three Mesh.

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

// this class extends Phaser.Physics.Arcade.Sprite
export class ThreePlatform extends Phaser.Physics.Arcade.Sprite {

    threeObject : THREE.Mesh;

    group : Phaser.Physics.Arcade.Group;

    scene : Phaser.Scene; 

    constructor(scene : Phaser.Scene, group : Phaser.Physics.Arcade.Group, threeScene : THREE.Scene) {
        super(scene, 0, 0, 'platform');
        this.group = group;
        this.scene = scene;
        let posX : number = (this.getRightmostPlatform() == 0) ? scene.game.config.width as number * GameOptions.playerStartXPosition : this.setPlatformX();
        this.setPosition(posX, this.setPlatformY());
        this.setOrigin(0.5, 1);
        this.displayWidth = Phaser.Math.Between(GameOptions.platformLengthRange[0], GameOptions.platformLengthRange[1]);
        this.displayHeight = GameOptions.platformThickness;
        scene.add.existing(this);
        scene.physics.add.existing(this);
        group.add(this);
        this.setImmovable(true);
        const geometry : THREE.BoxGeometry = new THREE.BoxGeometry(10, 10, 80);
        const material : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
            color : 0x00ff00
        });
        this.threeObject = new THREE.Mesh(geometry, material);
        this.threeObject.receiveShadow = true;
        this.threeObject.position.x = posX;
        this.threeObject.position.y = this.scene.game.config.height as number - this.y + this.displayHeight / 2;
        this.threeObject.scale.x = this.displayWidth / 10;
        this.threeObject.scale.y = this.displayHeight / 10;
        threeScene.add(this.threeObject);
    }

    getRightmostPlatform() : number {
        let rightmostPlatform : number = 0;
        this.group.getChildren().forEach((platform) => {
            let platformObject : Phaser.Physics.Arcade.Sprite = platform as Phaser.Physics.Arcade.Sprite;
            rightmostPlatform = Math.max(rightmostPlatform, platformObject.x);
        });
        return rightmostPlatform;
    }

    // method to set a random platform X position
    setPlatformX() : number {
        return this.getRightmostPlatform() + Phaser.Math.Between(GameOptions.platformDistanceRange[0], GameOptions.platformDistanceRange[1]);
    }
 
    // method to set a random platform Y position
    setPlatformY() : number {
        return Phaser.Math.Between(this.scene.game.config.height as number * GameOptions.platformHeightRange[0], this.scene.game.config.height as number * GameOptions.platformHeightRange[1]);
    }

    preUpdate() : void {
        if (this.getBounds().right < -100) {
            this.x = this.setPlatformX();
            this.y = this.setPlatformY();   
            this.displayWidth = Phaser.Math.Between(GameOptions.platformLengthRange[0], GameOptions.platformLengthRange[1]);
            this.threeObject.scale.x = this.displayWidth / 10; 
            this.threeObject.position.y = this.scene.game.config.height as number - this.y + this.displayHeight / 2;
        }
        this.threeObject.position.x = this.x;
    }
}

Now that I managed to make Phaser and Three work, nothing will stop me from building the infinite staircase. Download the source code of the entire project.

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