HTML5 physics driven Concentration game prototype using Phaser and Planck.js – rewriting to TypeScript and adding images

Read all posts about "" game

Planck.js is a great JavaScript library to add Box2D physics to your HTML5 games, and I already published some examples which you can find in the Box2D section of the blog.

This new example improves the physics driven Concentration prototype by rewriting it to TypeScript and using images rather than texts.

Let’s have a look at the prototype:

Click or tap on two covered boxes to uncover them, if pictures match, both boxes will be removed.

Just like in the original prototype, keep in mind that at the moment there is not game over condition – it should be when the stack of boxes becomes too high – and there’s no object pooling.

Do you like those cute animal icons? Then you can absolutely visit Kenney’s website, which provides a lot of free high quality art.

You can include Planck.js in your project with

npm install planck -dev

If you don’t know what I am talking about, then follow this tutorial and the second step and, why not, the 3rd step too.

Ok, now let’s have a look at the resources used in the game:

tiles.png: a sprite sheet containing all tiles

wall.png: an image to render game walls.

Now, let’s have a look at the completely commented source code, which consists in one html file, one css file and 7 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">
        <style type = "text/css">
            * {
                padding: 0;
                margin: 0;
            }
            body{
                background: #000;
            }
            canvas {
                touch-action: none;
                -ms-touch-action: none;
            }
        </style>
        <script src = "main.js"></script>
    </head>
    <body>
        <div id = "thegame"></div>
    </body>
</html>

style.css

The style sheet of the main web page.

* {
    padding : 0;
    margin : 0;
}

canvas {
    touch-action : none;
    -ms-touch-action : none;
}

gameOptions.ts

Configurable game options.

// CONFIGURABLE GAME OPTIONS

export const GameOptions = {

    // world scale to convert Box2D meters to pixels. 1 meter = 30 pixels
    worldScale: 30,
 
    // game gravity
    gameGravity: 8,
 
    // amount of boxes when the game starts
    startingBoxes: 16,
 
    // box size, in pixels
    boxSize: 100,
 
    // delay between two boxes, in milliseconds
    boxDelay: 1500,
 
    // time before box value is hidden, in milliseconds
    timeBeforeHide: 2000
}

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 : 700,
    height : 1244
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
    type : Phaser.AUTO,
    backgroundColor : 0x1dc8fc,
    scale : scaleObject,
    scene : [PreloadAssets, PlayGame]
}

// the game itself
new Phaser.Game(configObject);

preloadAssets.ts

Here we preload all assets to be used in the game, such as the sprite sheet and the image to be used for the walls.

// 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 a sprite sheet
        this.load.spritesheet('tiles', 'assets/tiles.png', {
            frameWidth: 136,
            frameHeight: 136
        });

        // this is how we preload an image
        this.load.image('wall', 'assets/wall.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 is stored here.

// THE GAME ITSELF

import * as planck from 'planck';
import { GameOptions } from './gameOptions';
import { PhysicsBox } from './physicsBox';
import { PhysicsWall } from './physicsWall';
import { toMeters, toPixels } from './planckUtils';

// this class extends Scene class
export class PlayGame extends Phaser.Scene {

    // Box2d world
    world : planck.World;

    // variable to store game width, in pixels
    gameWidth: number;

    // variable to store game height, in pixels
    gameHeight : number;

    // array where to store the two selected boxes
    selectedBoxes : planck.Body[];

    // constructor
    constructor() {
        super({
            key: 'PlayGame'
        });
    }

    // method to be executed when the scene has been created
    create() : void {  

        // save game width and height in a variable
        this.gameWidth = this.game.config.width as number;
        this.gameHeight = this.game.config.height as number;

        // world gravity, as a Vec2 object. It's just a x, y vector
        let gravity = new planck.Vec2(0, GameOptions.gameGravity); 
        
        // this is how we create a Box2D world
        this.world = new planck.World(gravity);

        // add static physics boxes
        new PhysicsWall(this, this.world, this.gameWidth / 2, this.gameHeight - 20, this.gameWidth, 40);
        new PhysicsWall(this, this.world, 20, this.gameHeight / 2, 40, this.gameHeight);
        new PhysicsWall(this, this.world, this.gameWidth - 20, this.gameHeight / 2, 40, this.gameHeight);

        // time event to place the first boxes
        let firstTimeEvent : Phaser.Time.TimerEvent = this.time.addEvent({

            // event delay, in milliseconds
            delay : 200,

            // how many times do we repeat the event?
            repeat : GameOptions.startingBoxes,

            // callback function
            callback: () => {

                // add a new physics box
                new PhysicsBox(this, this.world, Phaser.Math.Between(100, this.gameWidth - 100), -100, GameOptions.boxSize, GameOptions.boxSize, GameOptions.timeBeforeHide);
               
                // is this the last time we have to repeat the event?
                if (firstTimeEvent.repeatCount == 0) {
 
                    // time event to place the remaining boxes
                    this.time.addEvent({

                        // event delay, in milliseconds
                        delay : GameOptions.boxDelay,
                        
                        // callback function
                        callback : () => {

                            // add a new physics box
                            new PhysicsBox(this, this.world, Phaser.Math.Between(100, this.gameWidth - 100), -100, 100, 100, GameOptions.timeBeforeHide);
                        },

                        // repeat the event forever
                        loop : true
                    });
                }
            }
        });

        // array where to store the two selected boxes
        this.selectedBoxes = [];

        // input listener
        this.input.on('pointerdown', this.selectBox, this);    
    }

    // method to select a box
    selectBox(event : Phaser.Input.Pointer) : void {
 
        // did we select less than 2 boxes?
        if (this.selectedBoxes.length < 2) {

            // loop through all bodies
            for (let body : planck.Body = this.world.getBodyList() as planck.Body; body; body = body.getNext() as planck.Body) {

                // loop through all fixtures
                for (let fixture : planck.Fixture = body.getFixtureList() as planck.Fixture; fixture; fixture = fixture.getNext() as planck.Fixture) {

                    // if the fixture contains the input coordinate...
                    if (fixture.testPoint(new planck.Vec2(toMeters(event.x), toMeters(event.y)))) {

                        // get body userData
                        let userData : any = body.getUserData();

                        // if the body is dynamic and covered
                        if (body.isDynamic() &amp;&amp; userData.covered) {

                            // show actual box face
                            userData.sprite.setFrame(userData.value);
                            
                            // the box is no longer covered
                            userData.covered = false;

                            // push the box in selectedBoxes array
                            this.selectedBoxes.push(body);

                            // does selectedBoxes array contain two boxes?
                            if (this.selectedBoxes.length == 2) {

                                // wait 1/2 seconds
                                this.time.addEvent({

                                    // event delay, in milliseconds
                                    delay : 500,
                                    
                                    // callback function
                                    callback : () => {

                                        // get userData of both boxes
                                        let userData : any[] = [this.selectedBoxes[0].getUserData(), this.selectedBoxes[1].getUserData()];

                                        // do boxes have the same value?
                                        if (userData[0].value == userData[1].value) {

                                            // destroy the sprites
                                            userData[0].sprite.destroy();
                                            userData[1].sprite.destroy();

                                            // destroy the bodies
                                            this.world.destroyBody(this.selectedBoxes[0]);
                                            this.world.destroyBody(this.selectedBoxes[1]);

                                        }

                                        // do boxes have different values?
                                        else {

                                            // hide boxes images
                                            userData[0].sprite.setFrame(10);
                                            userData[1].sprite.setFrame(10);

                                            // set boxes as covered
                                            userData[0].covered = true;
                                            userData[1].covered = true;
                                        }

                                        // empty selectedBoxes array
                                        this.selectedBoxes = [];
                                    }
                                })
                            }
                        }
                    }
                }
            }
        }
    }

    // method to be executed at each frame
    update() : void {
 
        // advance the simulation by 1/30 seconds
        this.world.step(1 / 30);
 
        // crearForces  method should be added at the end on each step
        this.world.clearForces();
 
        // iterate through all bodies
        for (let body : planck.Body = this.world.getBodyList() as planck.Body; body; body = body.getNext() as planck.Body) {
 
            // get body position
            let bodyPosition : planck.Vec2 = body.getPosition();
 
            // get body angle, in radians
            let bodyAngle : number = body.getAngle();
 
            // get body user data, the graphics object
            let userData : any = body.getUserData();
 
            // adjust graphic object position and rotation
            userData.sprite.x = toPixels(bodyPosition.x);
            userData.sprite.y = toPixels(bodyPosition.y);
            userData.sprite.rotation = bodyAngle;
        }
    }
}

planckUtils.ts

Just a couple of functions to convert pixels, used by Phaser, to meters, used by Planck, and to convert pixels to meters.

import { GameOptions } from './gameOptions';

// simple function to convert pixels to meters
export function toMeters(n : number) : number {
    return n / GameOptions.worldScale;
}

// simple function to convert meters to pixels
export function toPixels(n: number) : number {
    return n * GameOptions.worldScale;
}

physicsWall.ts

Custom class for the physics walls, a static Box2D body.

import * as planck from 'planck';
import { toMeters } from './planckUtils';

// this class extends Phaser Sprite class
export class PhysicsWall extends Phaser.GameObjects.Sprite {

    constructor(scene : Phaser.Scene, world : planck.World, posX : number, posY : number, width : number, height : number) {

        super(scene, posX, posY, 'wall');
        
        // adjust sprite display width and height
        this.displayWidth = width;
        this.displayHeight = height;

        // add sprite to scene
        scene.add.existing(this);
    
        // this is how we create a generic Box2D body
        let box : planck.Body = world.createBody();

        // a body can have one or more fixtures. This is how we create a box fixture inside a body
        box.createFixture(planck.Box(toMeters(width / 2), toMeters(height / 2)));
 
        // now we place the body in the world
        box.setPosition(planck.Vec2(toMeters(posX), toMeters(posY)));

        // time to set mass information
        box.setMassData({

            // body mass
            mass : 1,

            // body center
            center : planck.Vec2(),
 
            // I have to say I do not know the meaning of this "I", but if you set it to zero, bodies won't rotate
            I : 1
        });

        // a body can have anything in its user data, normally it's used to store its sprite
        box.setUserData({
            sprite : this,
        });      
    }
}

physicsBox.ts

Custom class for the physics box, a dynamic Box2D body.

import * as planck from 'planck';
import { toMeters } from './planckUtils';

// this class extends planck Phaser Sprite class
export class PhysicsBox extends Phaser.GameObjects.Sprite {

    constructor(scene : Phaser.Scene, world : planck.World, posX : number, posY : number, width : number, height : number, hideAfter : number) {

        super(scene, posX, posY, 'tiles');
        
        // adjust sprite display width and height
        this.displayWidth = width;
        this.displayHeight = height;

        // add sprite to scene
        scene.add.existing(this);
    
        // this is how we create a generic Box2D body
        let box : planck.Body = world.createBody();

        // Box2D bodies are created as static bodies, but we can make them dynamic
        box.setDynamic();

        // a body can have one or more fixtures. This is how we create a box fixture inside a body
        box.createFixture(planck.Box(toMeters(width / 2), toMeters(height / 2)));
 
        // now we place the body in the world
        box.setPosition(planck.Vec2(toMeters(posX), toMeters(posY)));

        // time to set mass information
        box.setMassData({

            // body mass
            mass : 1,

            // body center
            center : planck.Vec2(),
 
            // I have to say I do not know the meaning of this "I", but if you set it to zero, bodies won't rotate
            I : 1
        });

        // initial random value
        let randomValue : number = -1;
 
        // set a random value
        randomValue = Phaser.Math.Between(0, 9);
 
        // set sprite frame to randomValue
        this.setFrame(randomValue);
        
        // set a timed event to hide box image
        let timedEvent : Phaser.Time.TimerEvent = scene.time.addEvent({

            // event delay
            delay : hideAfter,

            // optional arguments: the sprite itself
            args : [this],

            // callback function
            callback : () => {

                // set frame to 10 (cover)
                this.setFrame(10);

                // get box user data
                let userData : any = box.getUserData();
            
                // set box as covered
                userData.covered = true;
            }
        });
        
        // a body can have anything in its user data, normally it's used to store its sprite
        box.setUserData({
            sprite : this,
            value : randomValue,
            covered : false,
            event : timedEvent
        });
    }
}

And now you can have your physics driven Concentration game. Remember to add a game over condition and a pooling system to save resources. 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

214 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
// 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