Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Perfect Square! game, Game development, HTML5, Javascript and Phaser.

My Perfect Square! tutorial series showed you how to build a HTML5 physics hyper casual game without physics at all, only using tweens.

It’s time to port it into “Modern” JavaScript, but most of all to explain what is Modern Javascript.

I am sorry if I am disappointing anyone, but there isn’t a “Modern” JavaScript or an “Ancient” JavaScript, and developers call “Modern” because it uses all the latest trends, such as AS6 syntax or import and export directives.

Anyway, we don’t know how JavaScript trends will evolve, and for sure what is called “Modern” today won’t be called “Modern” tomorrow, so I’d rather call it “Latest” or “Current”.

Enough with the boring theory, and let’s code.

Most Modern JavaScript tutorials rely on Node.js and webpack to bundle your scripts and assets into distribution files, but it’s possible to start coding using Modern JavaScript and distribute your results without any package manager.

This is the game we are going to build, which is exactly the same as the one running at this post.

Tap and hold to make the square grow, release to drop it. Don’t make it fall down the hole or hit the ground. There are also game instructions.

What makes this game different is the source code, starting from index.html:

<!DOCTYPE html>
<html>
    <head>
        <style type="text/css">
            body {
                background: #000000;
                padding: 0px;
                margin: 0px;
            }
        </style>
        <script src="phaser.min.js"></script>
        <script type="module" src="game.js"></script>
    </head>
    <body>
        <div id = "thegame"></div>
    </body>
</html>

Look how at line 12 we are loading game script as a module, to allow JavaScript scripts to be split into separate modules that can be imported when needed.

This allows us to reuse the code, and reusing the code is always a good thing, I am saying it since 2011.

Let’s have a look at game.js:

import PreloadAssets from './scenes/PreloadAssets.js'
import PlayGame from './scenes/PlayGame.js'

const config = {
    type: Phaser.AUTO,
    scale: {
        mode: Phaser.Scale.FIT,
        autoCenter: Phaser.Scale.CENTER_BOTH,
        parent: "thegame",
        width: 640,
        height: 960
    },
    scene: [PreloadAssets, PlayGame]
}

export default new Phaser.Game(config)

It’s easy to see we are importing PreloadAssets and PlayGame scenes from separate files, and exporting the game itself.

It’s easy to see how PreloadAssets.js is easily reusable, we can just copy and paste it in our project and simply edit the keys/paths we are going to change:

export default class PreloadAssets extends Phaser.Scene {
	constructor() {
		super("PreloadAssets")
	}
    preload() {
        this.load.image("base", "assets/base.png");
        this.load.image("square", "assets/square.png");
        this.load.image("top", "assets/top.png");
        this.load.bitmapFont("font", "assets/font.png", "assets/font.fnt");
	}
	create() {
        this.scene.start("PlayGame");
	}
}

PlayGame.js is the biggest file because it contains the game itself, but I created some more classes such as PlayerSquare, SquareText and GameWall which are imported separately, as well as GameOptions.js and GameModes.js which contain respectively the global game options and the game modes.

Here is PlayGame.js:

import {GAMEOPTIONS} from './GameOptions.js'
import * as gameMode from './GameModes.js'
import PlayerSquare from './PlayerSquare.js'
import SquareText from './SquareText.js'
import GameWall from './GameWall.js'

export default class PlayGame extends Phaser.Scene {
	constructor() {
		super("PlayGame")
	}
    create() {
        this.saveData = localStorage.getItem(GAMEOPTIONS.localStorageName) == null ? { level: 1} : JSON.parse(localStorage.getItem(GAMEOPTIONS.localStorageName));
        let tintColor = Phaser.Utils. Array.GetRandom(GAMEOPTIONS.bgColors);
        this.cameras.main.setBackgroundColor(tintColor);
        this.leftSquare = new GameWall(this, 0, this.game.config.height, "base", new Phaser.Math.Vector2(1, 1));
        this.add.existing(this.leftSquare);
        this.rightSquare = new GameWall(this, this.game.config.width, this.game.config.height, "base", new Phaser.Math.Vector2(0, 1));
        this.add.existing(this.rightSquare);
        this.leftWall = new GameWall(this, 0, this.game.config.height - this.leftSquare.height, "top", new Phaser.Math.Vector2(1, 1));
        this.add.existing(this.leftWall);
        this.rightWall = new GameWall(this, this.game.config.width, this.game.config.height - this.leftSquare.height, "top", new Phaser.Math.Vector2(0, 1));
        this.add.existing(this.rightWall);
        this.square = new PlayerSquare(this, this.game.config.width / 2, -400, "square");
		this.add.existing(this.square);
		this.squareText = new SquareText(this, this.square.x, this.square.y, "font", this.saveData.level, 120, tintColor);
		this.add.existing(this.squareText);
        this.squareTweenTargets = [this.square, this.squareText];
        this.levelText = this.add.bitmapText(this.game.config.width / 2, 0, "font", "level " + this.saveData.level, 60);
        this.levelText.setOrigin(0.5, 0);
        this.updateLevel();
        this.input.on("pointerdown", this.grow, this);
        this.input.on("pointerup", this.stop, this);
        this.gameMode = gameMode.IDLE;
	}
    updateLevel() {
        let holeWidth = Phaser.Math.Between(GAMEOPTIONS.holeWidthRange[0], GAMEOPTIONS.holeWidthRange[1]);
        let wallWidth = Phaser.Math.Between(GAMEOPTIONS.wallRange[0], GAMEOPTIONS.wallRange[1]);
        this.leftSquare.tweenTo((this.game.config.width - holeWidth) / 2);
        this.rightSquare.tweenTo((this.game.config.width + holeWidth) / 2);
        this.leftWall.tweenTo((this.game.config.width - holeWidth) / 2 - wallWidth);
        this.rightWall.tweenTo((this.game.config.width + holeWidth) / 2 + wallWidth);
        let squareTween = this.tweens.add({
            targets: this.squareTweenTargets,
            y: 150,
            scaleX: 0.2,
            scaleY: 0.2,
            angle: 50,
            duration: 500,
            ease: "Cubic.easeOut",
            callbackScope: this,
            onComplete: function() {
                this.rotateTween = this.tweens.add({
                    targets: this.squareTweenTargets,
                    angle: 40,
                    duration: 300,
                    yoyo: true,
                    repeat: -1
                });
                if (this.square.successful == 0) {
                    this.addInfo(holeWidth, wallWidth);
                }
                this.gameMode = gameMode.WAITING;
            }
        })
    }
    grow() {
        if (this.gameMode == gameMode.WAITING) {
            this.gameMode = gameMode.GROWING;
            if (this.square.successful == 0) {
                this.infoGroup.toggleVisible();
            }
            this.growTween = this.tweens.add({
                targets: this.squareTweenTargets,
                scaleX: 1,
                scaleY: 1,
                duration: GAMEOPTIONS.growTime
            });
        }
    }
    stop() {
        if (this.gameMode == gameMode.GROWING) {
            this.gameMode = gameMode.IDLE;
            this.growTween.stop();
            this.rotateTween.stop();
            this.rotateTween = this.tweens.add({
                targets: this.squareTweenTargets,
                angle: 0,
                duration:300,
                ease: "Cubic.easeOut",
                callbackScope: this,
                onComplete: function(){
                    if (this.square.displayWidth <= this.rightSquare.x - this.leftSquare.x) {
                        this.tweens.add({
                            targets: this.squareTweenTargets,
                            y: this.game.config.height + this.square.displayWidth,
                            duration:600,
                            ease: "Cubic.easeIn",
                            callbackScope: this,
                            onComplete: function(){
                                this.levelText.text = "Oh no!!!";
                                this.gameOver();
                            }
                        })
                    }
                    else{
                        if (this.square.displayWidth <= this.rightWall.x - this.leftWall.x) {
                            this.fallAndBounce(true);
                        }
                        else{
                            this.fallAndBounce(false);
                        }
                    }
                }
            })
        }
    }
    fallAndBounce(success) {
        let destY = this.game.config.height - this.leftSquare.displayHeight - this.square.displayHeight / 2;
        let message = "Yeah!!!!";
        if (success) {
            this.square.successful ++;
        }
        else {
            destY = this.game.config.height - this.leftSquare.displayHeight - this.leftWall.displayHeight - this.square.displayHeight / 2;
            message = "Oh no!!!!";
        }
        this.tweens.add({
            targets: this.squareTweenTargets,
            y: destY,
            duration:600,
            ease: "Bounce.easeOut",
            callbackScope: this,
            onComplete: function(){
                this.levelText.text = message;
                if (!success) {
                    this.gameOver();
                }
                else{
                    this.time.addEvent({
                        delay: 1000,
                        callback: function(){
                            if (this.square.successful == this.saveData.level) {
                                this.saveData.level ++;
                                localStorage.setItem(GAMEOPTIONS.localStorageName, JSON.stringify({
                                    level: this.saveData.level
                                }));
                                this.scene.start("PlayGame");
                            }
                            else {
                                this.squareText.updateText(this.saveData.level - this.square.successful);
                                this.levelText.text = "level " + this.saveData.level;
                                this.updateLevel();
                            }
                        },
                        callbackScope: this
                    });
                }
            }
        })
    }
    addInfo(holeWidth, wallWidth) {
        this.infoGroup = this.add.group();
        let targetSquare = this.add.sprite(this.game.config.width / 2, this.game.config.height - this.leftSquare.displayHeight, "square");
        targetSquare.displayWidth = holeWidth + wallWidth;
        targetSquare.displayHeight = holeWidth + wallWidth;
        targetSquare.alpha = 0.3;
        targetSquare.setOrigin(0.5, 1);
        this.infoGroup.add(targetSquare);
        let targetText = this.add.bitmapText(this.game.config.width / 2, targetSquare.y - targetSquare.displayHeight - 20, "font", "land here", 48);
        targetText.setOrigin(0.5, 1);
        this.infoGroup.add(targetText);
        let holdText = this.add.bitmapText(this.game.config.width / 2, 250, "font", "tap and hold to grow", 40);
        holdText.setOrigin(0.5, 0);
        this.infoGroup.add(holdText);
        let releaseText = this.add.bitmapText(this.game.config.width / 2, 300, "font", "release to drop", 40);
        releaseText.setOrigin(0.5, 0);
        this.infoGroup.add(releaseText);
    }
    gameOver() {
        this.time.addEvent({
            delay: 1000,
            callback: function() {
                this.scene.start("PlayGame");
            },
            callbackScope: this
        });
    }
}

It’s quite similar to game.js in the original version but I created a different module for game options which can also be cut and pasted into new project and properly edited, here is GameOptions.js:

export const GAMEOPTIONS = {
    bgColors: [0x62bd18, 0xff5300, 0xd21034, 0xff475c, 0x8f16b2, 0x588c7e, 0x8c4646],
    holeWidthRange: [80, 260],
    wallRange: [10, 50],
    growTime: 1500,
    localStorageName: "squaregamephaser3"
}

And the same thing goes for GameModes.js:

export const IDLE = 0;
export const WAITING = 1;
export const GROWING = 2;

Then we have PlayerSquare.js which defines player class:

export default class PlayerSquare extends Phaser.GameObjects.Sprite {
	constructor(scene, x, y, key) {
		super(scene, x, y, key);
        this.successful = 0;
        this.setScale(0.2);
	}
}

SquareText.js which defines the number written on the square:

export default class SquareText extends Phaser.GameObjects.BitmapText {
	constructor(scene, x, y, font, text, size, tintColor) {
		super(scene, x, y, font, text, size);
		this.setOrigin(0.5);
        this.setScale(0.4);
        this.setTint(tintColor);
	}
    updateText(text) {
        this.setText(text);
    }
}

And GameWall.js which defines the moving walls at the bottom:

export default class GameWall extends Phaser.GameObjects.Sprite {
	constructor(scene, x, y, key, origin) {
		super(scene, x, y, key);
        this.setOrigin(origin.x, origin.y);
	}
    tweenTo(x) {
        this.scene.tweens.add({
            targets: this,
            x: x,
            duration: 500,
            ease: "Cubic.easeOut"
        });
    }
}

And I could have coded more classes for the text instructions, tweens and so on, but I am sure you got the point.

The more you split your code into classes, the more readable and reusable is.

Do you prefer Modern JavaScript or “Once Modern” JavaScript? Download the source code and compare it with the one of the original example.

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