“Bouncy Light” HTML5 prototype built using Phaser to handle game logic and Three to render 3D graphics, written in TypeScript

Read all posts about "" game

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.

Get the most popular Phaser 3 book

Through 202 pages, 32 source code examples and an Android Studio project you will learn how to build cross platform HTML5 games and create a complete game along the way.

Get the book

215 GAME PROTOTYPES EXPLAINED WITH SOURCE CODE
// 1+2=3
// 100 rounds
// 10000000
// 2 Cars
// 2048
// A Blocky Christmas
// A Jumping Block
// A Life of Logic
// Angry Birds
// Angry Birds Space
// Artillery
// Astro-PANIC!
// Avoider
// Back to Square One
// Ball Game
// Ball vs Ball
// Ball: Revamped
// Balloon Invasion
// BallPusher
// Ballz
// Bar Balance
// Bejeweled
// Biggification
// Block it
// Blockage
// Bloons
// Boids
// Bombuzal
// Boom Dots
// Bouncing Ball
// Bouncing Ball 2
// Bouncy Light
// BoxHead
// Breakout
// Bricks
// Bubble Chaos
// Bubbles 2
// Card Game
// Castle Ramble
// Chronotron
// Circle Chain
// Circle Path
// Circle Race
// Circular endless runner
// Cirplosion
// CLOCKS - The Game
// Color Hit
// Color Jump
// ColorFill
// Columns
// Concentration
// Crossy Road
// Crush the Castle
// Cube Jump
// CubesOut
// Dash N Blast
// Dashy Panda
// Deflection
// Diamond Digger Saga
// Don't touch the spikes
// Dots
// Down The Mountain
// Drag and Match
// Draw Game
// Drop Wizard
// DROP'd
// Dudeski
// Dungeon Raid
// Educational Game
// Elasticity
// Endless Runner
// Erase Box
// Eskiv
// Farm Heroes Saga
// Filler
// Flappy Bird
// Fling
// Flipping Legend
// Floaty Light
// Fuse Ballz
// GearTaker
// Gem Sweeper
// Globe
// Goat Rider
// Gold Miner
// Grindstone
// GuessNext
// Helicopter
// Hero Emblems
// Hero Slide
// Hexagonal Tiles
// HookPod
// Hop Hop Hop Underwater
// Horizontal Endless Runner
// Hundreds
// Hungry Hero
// Hurry it's Christmas
// InkTd
// Iromeku
// Jet Set Willy
// Jigsaw Game
// Knife Hit
// Knightfall
// Legends of Runeterra
// Lep's World
// Line Rider
// Lumines
// Magick
// MagOrMin
// Mass Attack
// Math Game
// Maze
// Meeblings
// Memdot
// Metro Siberia Underground
// Mike Dangers
// Mikey Hooks
// Nano War
// Nodes
// o:anquan
// One Button Game
// One Tap RPG
// Ononmin
// Pacco
// Perfect Square!
// Perfectionism
// Phyballs
// Pixel Purge
// PixelField
// Planet Revenge
// Plants Vs Zombies
// Platform
// Platform game
// Plus+Plus
// Pocket Snap
// Poker
// Pool
// Pop the Lock
// Pop to Save
// Poux
// Pudi
// Pumpkin Story
// Puppet Bird
// Pyramids of Ra
// qomp
// Quick Switch
// Racing
// Radical
// Rebuild Chile
// Renju
// Rise Above
// Risky Road
// Roguelike
// Roly Poly
// Run Around
// Rush Hour
// SameGame
// SamePhysics
// Save the Totem
// Security
// Serious Scramblers
// Shrink it
// Sling
// Slingy
// Snowflakes
// Sokoban
// Space Checkers
// Space is Key
// Spellfall
// Spinny Gun
// Splitter
// Spring Ninja
// Sproing
// Stabilize!
// Stack
// Stairs
// Stick Hero
// String Avoider
// Stringy
// Sudoku
// Super Mario Bros
// Surfingers
// Survival Horror
// Talesworth Adventure
// Tetris
// The Impossible Line
// The Moops - Combos of Joy
// The Next Arrow
// Threes
// Tic Tac Toe
// Timberman
// Tiny Wings
// Tipsy Tower
// Toony
// Totem Destroyer
// Tower Defense
// Trick Shot
// Tunnelball
// Turn
// Turnellio
// TwinSpin
// vvvvvv
// Warp Shift
// Way of an Idea
// Whack a Creep
// Wheel of Fortune
// Where's my Water
// Wish Upon a Star
// Word Game
// Wordle
// Worms
// Yanga
// Yeah Bunny
// Zhed
// zNumbers