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

Read all posts about "" game

A couple of years ago I covered Perfect Square! game with a tutorial series.

It may seem a physics game where you have to grow a square in a way it will perfectly fit in a hole, but I was able to create a fully working prototype using only tweens.

Have a look at the game, there are in-game instructions too!

Did you manage to advance levels? Good. All the physics stuff you see in the game is driven by tweens. The square floats, falls and bounces on the ground only thanks to tweens.

At the moment I updated the code to Phaser 3, finding these two issues:

1 – for some reason, in Phaser 3.15.* I wasn’t able to make cameras.main.setBackgroundColor work.

2 – I had some troubles in changing the text on the falling square, it seems I wasn’t able to keep the origin – or anchor point – in place.

The rest of the code works like a charm, have a look at the uncommented code but a commented version is coming soon:

let game;
let saveData;
let gameOptions = {
    bgColors: [0x62bd18, 0xff5300, 0xd21034, 0xff475c, 0x8f16b2, 0x588c7e, 0x8c4646],
    holeWidthRange: [80, 260],
    wallRange: [10, 50],
    growTime: 1500,
    localStorageName: "squaregamephaser3"
}
const IDLE = 0;
const WAITING = 1;
const GROWING = 2;
window.onload = function() {
    let width = 640;
    let height = 960;
    let windowRatio = window.innerWidth / window.innerHeight;
    if(windowRatio < width / height){
        height = width / windowRatio;
    }
    var gameConfig = {
        width: width,
        height: height,
        scene: playGame,
        backgroundColor: 0x444444
    }
    game = new Phaser.Game(gameConfig);
    window.focus()
    resize();
    window.addEventListener("resize", resize, false);
}
class playGame extends Phaser.Scene{
    constructor(){
        super("PlayGame");
    }
    preload(){
        this.load.image("base", "base.png");
        this.load.image("square", "square.png");
        this.load.image("top", "top.png");
        this.load.bitmapFont("font", "font.png", "font.fnt");
    }
    create(){
        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 = this.add.sprite(0, game.config.height, "base");
        this.leftSquare.setOrigin(1, 1);
        this.rightSquare = this.add.sprite(game.config.width, game.config.height, "base");
        this.rightSquare.setOrigin(0, 1);
        this.leftWall = this.add.sprite(0, game.config.height - this.leftSquare.height, "top");
        this.leftWall.setOrigin(1, 1);
        this.rightWall = this.add.sprite(game.config.width, game.config.height - this.rightSquare.height, "top");
        this.rightWall.setOrigin(0, 1);
        this.square = this.add.sprite(game.config.width / 2, -400, "square");
        this.square.successful = 0;
        this.square.setScale(0.2);
        this.squareText = this.add.bitmapText(game.config.width / 2, -400, "font", (saveData.level - this.square.successful).toString(), 120);
        this.squareText.setOrigin(0.5);
        this.squareText.setScale(0.4);
        this.squareText.setTint(tintColor);
        this.levelText = this.add.bitmapText(game.config.width / 2, 0, "font", "level " + 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 = 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.placeWall(this.leftSquare, (game.config.width - holeWidth) / 2);
        this.placeWall(this.rightSquare, (game.config.width + holeWidth) / 2);
        this.placeWall(this.leftWall, (game.config.width - holeWidth) / 2 - wallWidth);
        this.placeWall(this.rightWall, (game.config.width + holeWidth) / 2 + wallWidth);
        let squareTween = this.tweens.add({
            targets: [this.square, this.squareText],
            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.square, this.squareText],
                    angle: 40,
                    duration: 300,
                    yoyo: true,
                    repeat: -1
                });
                if(this.square.successful == 0){
                    this.addInfo(holeWidth, wallWidth);
                }
                this.gameMode = WAITING;
            }
        })
    }
    placeWall(target, posX){
        this.tweens.add({
            targets: target,
            x: posX,
            duration: 500,
            ease: "Cubic.easeOut"
        });
    }
    grow(){
        if(this.gameMode == WAITING){
            this.gameMode = GROWING;
            if(this.square.successful == 0){
                this.infoGroup.toggleVisible();
            }
            this.growTween = this.tweens.add({
                targets: [this.square, this.squareText],
                scaleX: 1,
                scaleY: 1,
                duration: gameOptions.growTime
            });
        }
    }
    stop(){
        if(this.gameMode == GROWING){
            this.gameMode = IDLE;
            this.growTween.stop();
            this.rotateTween.stop();
            this.rotateTween = this.tweens.add({
                targets: [this.square, this.squareText],
                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.square, this.squareText],
                            y: 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 = game.config.height - this.leftSquare.displayHeight - this.square.displayHeight / 2;
        let message = "Yeah!!!!";
        if(success){
            this.square.successful ++;
        }
        else{
            destY = game.config.height - this.leftSquare.displayHeight - this.leftWall.displayHeight - this.square.displayHeight / 2;
            message = "Oh no!!!!";
        }
        this.tweens.add({
            targets: [this.square, this.squareText],
            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 == saveData.level){
                                saveData.level ++;
                                localStorage.setItem(gameOptions.localStorageName, JSON.stringify({
                                    level: saveData.level
                                }));
                                this.scene.start("PlayGame");
                            }
                            else{
                                this.squareText.text = saveData.level - this.square.successful;
                                this.squareText.setOrigin(1, 1)
                                this.levelText.text = "level " + saveData.level;
                                this.updateLevel();
                            }
                        },
                        callbackScope: this
                    });
                }
            }
        })
    }
    addInfo(holeWidth, wallWidth){
        this.infoGroup = this.add.group();
        let targetSquare = this.add.sprite(game.config.width / 2, 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(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(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(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
        });
    }
}
function resize() {
    let canvas = document.querySelector("canvas");
    let windowWidth = window.innerWidth;
    let windowHeight = window.innerHeight;
    let windowRatio = windowWidth / windowHeight;
    let gameRatio = game.config.width / game.config.height;
    if(windowRatio < gameRatio){
        canvas.style.width = windowWidth + "px";
        canvas.style.height = (windowWidth / gameRatio) + "px";
    }
    else{
        canvas.style.width = (windowHeight * gameRatio) + "px";
        canvas.style.height = windowHeight + "px";
    }
}

Which level did you reach? Give me feedback or download the source code and play with it.

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