HTML5 prototype of iOS game “Perfect Square!” updated to Phaser 3 and Modern JavaScript – Build a physics game using only tweens

Read all posts about "" game

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.

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