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.