Get the full commented source code of

HTML5 Suika Watermelon Game

Talking about Watermelon Game game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.

This is a massive update to my Watermelon Game prototype and includes a lot of new stuff, such as fancy graphics, score, game over, “next” icon and an animated background.


All posts in this tutorial series:

Step 1: setup and basic game mechanics.

Step 2: delays and explosions.

Step 3: particle effects and more space for customization.

Step 4: how to handle user input.

Step 5: scrolling background, “next” icon and game over condition.

Step 6: saving best score with local storage and using object pooling to save resources.

Step 7: square and pentagon bodies added. Get the full source code on Gumroad.


For the first time in the blog, I am using the nine-slice game object and I have to say it’s awesome when you have to create panels and UI, but I will talk about it in a separate blog post.

Now let’s have a look at the prototype:

Drop and merge balls and enjoy.

Now the prototype is fully playable, but I will also add a couple of sounds effects and the high score in the final version.

To use Box2D powered by Planck.js you should install this package with:

npm install –save planck

If you don’t know how to install a npm package or set up a project this way, I wrote a free minibook explaining everything you need to know to get started.

I also added more comments to source code, which consists in one HTML file, one CSS file and seven TypeScript files:

index.html

The web page which hosts the game, to be run inside thegame element.

HTML
<!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.

CSS
* {
    padding : 0;
    margin : 0;
}

body {
    background-color : #000000;    
}

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. I also grouped the variables to keep them more organized.

TypeScript
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
 
export const GameOptions : any = {
    
    // world gravity
    gravity : 20,

    // pixels / meters ratio
    worldScale : 30,

    // field size
    field : {
        width : 500,
        height : 700,
        distanceFromBottom : 70
    },

    // set of bodies
    bodies : [
        { size : 30, color : 0xf5221c, particleSize : 10, score : 1 },
        { size : 40, color : 0x26e24e, particleSize : 20, score : 2 },
        { size : 50, color : 0xfeec27, particleSize : 30, score : 3 },
        { size : 60, color : 0xff49b2, particleSize : 40, score : 4 },
        { size : 70, color : 0x1ac6f9, particleSize : 50, score : 5 },
         { size : 80, color : 0x7638c8, particleSize : 60, score : 6 },
        { size : 90, color : 0x925f2c, particleSize : 70, score : 7 },
        { size : 100, color : 0xff8504, particleSize : 80, score : 8 },
        { size : 110, color : 0xf2f2f2, particleSize : 90, score : 9 },
        { size : 120, color : 0x888888, particleSize : 100, score : 10 },
        { size : 130, color : 0x2f2f2d, particleSize : 100, score : 11 }
    ],

    // idle time after the player dropped the ball
    idleTime : 1000,

    // maximum size of the ball to be dropped
    maxStartBallSize : 4,

    // blast radius. Actually is not a radius, but it works. In pixels.
    blastRadius : 100,

    // blast force applied
    blastImpulse : 2
}

main.ts

This is where the game is created, with all Phaser related options.

TypeScript
// MAIN GAME FILE

// modules to import
import Phaser from 'phaser';
import { PlayGame } from './playGame';
import { PreloadAssets } from './preloadAssets';
import { MainGame } from './mainGame';
import { AnimatedBackground } from './animatedBackground';

// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
    mode : Phaser.Scale.FIT,
    autoCenter : Phaser.Scale.CENTER_BOTH,
    parent : 'thegame',
    width : 1920,
    height : 1080
}

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

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

preloadAssets.ts

Here we preload all assets to be used in the game.

TypeScript
// CLASS TO PRELOAD ASSETS
  
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
  
    // constructor    
    constructor() {
        super({
            key : 'PreloadAssets'
        });
    }
  
    // method to be called during class preloading
    preload() : void {
 
        // this is how to load an image
        this.load.image('ball', 'assets/sprites/ball.png');
        this.load.image('background', 'assets/sprites/background.png');
        this.load.image('angle', 'assets/sprites/angle.png');
        this.load.image('line', 'assets/sprites/line.png');
        this.load.image('vertical', 'assets/sprites/vertical.png');
        this.load.image('panel', 'assets/sprites/panel.png');

        // this is how to load a sprite sheet
        this.load.spritesheet('faces', 'assets/sprites/faces.png', {
            frameWidth : 45,
            frameHeight : 25    
        });

        // this is how to load a bitmap font
        this.load.bitmapFont('font', 'assets/fonts/font.png', 'assets/fonts/font.fnt'); 
    }
  
    // method to be called once the instance has been created
    create() : void {
 
        // call MainGameScene
        this.scene.start('MainGame');
    }
}

mainGame.ts

Main game scene, which calls all game scenes, int his case AnimatedBackground and PlayGame.

TypeScript
// MAIN GAME SCENE

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

    constructor() {
        super({
            key : 'MainGame'
        });
    }

    create() : void {
        
        // how to launch two scenes simultaneously
        this.scene.launch('AnimatedBackground');
        this.scene.launch('PlayGame');
    }
}

animatedBackground.ts

Class to show the animated background. I decided to keep the animated background in a separate scene to allow players to restart the game without resetting background animation.

For more information about this technique, check this post.

TypeScript
// THE ANIMATED BACKGROUND

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

    constructor() {
        super({
            key : 'AnimatedBackground'
        });
    }

    create() : void {
        
        // place a big tilesprite
        const background : Phaser.GameObjects.TileSprite = this.add.tileSprite(0, -128, this.game.config.width as number + 128, this.game.config.height as number + 128, 'background');
        background.setOrigin(0);
            
        // slowly continuously tween the tilesprite, then place it in its original position, repeat forever
        this.tweens.add({
            targets : background,
            x : -128,
            y : 0,
            duration : 5000,
            onComplete : () => {
                background.setPosition(0, -128);
            },
            repeat : -1
        })    
    }
}

playGame.ts

Main game file, all game logic is stored here.

TypeScript
// THE GAME ITSELF

import Planck, { Circle } from 'planck';
import { GameOptions } from './gameOptions';
import { toMeters, toPixels } from './planckUtils';

// body type, can be a ball or a wall
enum bodyType {
    BALL,
    WALL
}

// game state, can be idle (nothing to do), moving (when you move the ball) or gameover, when the game is over
enum gameState {
    IDLE,
    MOVING,
    GAMEOVER
}

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

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

    world : Planck.World;
    contactManagement : any[];
    ballsAdded : number;
    currentBall : number;
    score : number;
    nextBall : number;
    ids : number[];
    emitters : Phaser.GameObjects.Particles.ParticleEmitter[];
    dummyBalls : Phaser.GameObjects.Sprite[];
    nextBallSprites : Phaser.GameObjects.Sprite[];
    currentState : gameState;
    scoreText : Phaser.GameObjects.BitmapText;
  
    // method to be called once the instance has been created
    create() : void {

        // initialize global variables
        this.ids = [];
        this.ballsAdded = 0;
        this.contactManagement = [];
        this.emitters = [];   
        this.dummyBalls = [];
        this.nextBallSprites = [];
        this.currentState = gameState.MOVING;
        this.currentBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
        this.nextBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
        this.score = 0;

        // build particle emitters
        this.buildEmitters();
       
        // create a Box2D world with gravity
        this.world = new Planck.World(new Planck.Vec2(0, GameOptions.gravity));

        // set some variables to build walls and various stuff
        const baseStartX : number = this.game.config.width as number / 2 - GameOptions.field.width / 2;
        const baseEndX : number = baseStartX + GameOptions.field.width;
        const baseStartY : number = this.game.config.height as number - GameOptions.field.distanceFromBottom;
        const baseEndY : number = baseStartY - GameOptions.field.height;
        const leftPanelCenter : number = baseStartX / 2;
        const leftPanelWidth : number = 400;

        // Box2D polygon where to make ball fall into
        this.createPolygon(baseStartX, baseStartY, baseEndX, baseEndY);

        // score panel
        this.add.nineslice(leftPanelCenter, 100, 'panel', 0, leftPanelWidth, 120, 33, 33, 33, 33);
        this.scoreText = this.add.bitmapText(leftPanelCenter + 180, 132, 'font', '0', 90);
        this.scoreText.setOrigin(1)
        this.scoreText.setTint(0x3d3d3d);

        // "next" panel
        this.add.nineslice(leftPanelCenter, 200, 'panel', 0, leftPanelWidth, 150 + GameOptions.bodies[GameOptions.maxStartBallSize].size * 2, 33, 33, 33, 33).setOrigin(0.5, 0);
        this.add.bitmapText(leftPanelCenter, 210, 'font', 'NEXT', 90).setTint(0x3d3d3d).setOrigin(0.5, 0);

        // game field
        this.add.tileSprite(baseStartX, baseStartY, GameOptions.field.width, 50, 'line').setOrigin(0);
        this.add.sprite(baseStartX - 32, baseStartY, 'angle').setOrigin(0);
        this.add.sprite(baseEndX, baseStartY, 'angle').setOrigin(0).setFlipX(true);
        this.add.tileSprite(baseStartX - 32, baseEndY, 32, GameOptions.field.height, 'vertical').setOrigin(0);
        this.add.tileSprite(baseEndX, baseEndY, 32, GameOptions.field.height, 'vertical').setOrigin(0);
        this.add.rectangle(baseStartX, baseEndY, GameOptions.field.width, GameOptions.field.height, 0x000000, 0.5).setOrigin(0);

        // create dummy and "next" balls. These aren't physics bodies, just sprites to be moved according to user input
        for (let i : number = 0; i <= GameOptions.maxStartBallSize; i ++) {
            const ball : Phaser.GameObjects.Sprite =  this.add.sprite(baseStartX, baseEndY - GameOptions.bodies[i].size, 'ball');
            ball.setAlpha(0.7);
            ball.setVisible(false);
            ball.setDisplaySize(GameOptions.bodies[i].size * 2, GameOptions.bodies[i].size  * 2);
            ball.setTint(GameOptions.bodies[i].color);
            this.dummyBalls.push(ball);
            const nextBall : Phaser.GameObjects.Sprite =  this.add.sprite(leftPanelCenter, 320 + GameOptions.bodies[GameOptions.maxStartBallSize].size, 'ball');
            nextBall.setVisible(false);
            nextBall.setTint(GameOptions.bodies[i].color);
            nextBall.setDisplaySize(GameOptions.bodies[i].size * 2, GameOptions.bodies[i].size  * 2);
            this.nextBallSprites.push(nextBall);
        }
        this.dummyBalls[this.currentBall].setVisible(true);
        this.nextBallSprites[this.nextBall].setVisible(true);

        // when the player releases the input...
        this.input.on('pointerup', (pointer : Phaser.Input.Pointer) => {
            
            // are we moving?
            if (this.currentState == gameState.MOVING) {

                // hide dummy ball
                this.dummyBalls[this.currentBall].setVisible(false);

                // create a new physics ball
                this.createBall(Phaser.Math.Clamp(pointer.x, baseStartX + GameOptions.bodies[this.currentBall].size, baseEndX - GameOptions.bodies[this.currentBall].size), baseEndY - GameOptions.bodies[this.currentBall].size, this.currentBall);
                
                // set the game state to IDLE
                this.currentState = gameState.IDLE; 

                // wait some time before adding a new ball
                this.time.addEvent({
                    delay: GameOptions.idleTime,
                    callback: (() => {
                        this.currentState = gameState.MOVING;
                        this.currentBall = this.nextBall;
                        this.nextBall = Phaser.Math.Between(0, GameOptions.maxStartBallSize);
                        this.dummyBalls[this.currentBall].setVisible(true);
                        this.nextBallSprites[this.currentBall].setVisible(false);
                        this.nextBallSprites[this.nextBall].setVisible(true);
                        this.dummyBalls[this.currentBall].setX(Phaser.Math.Clamp(pointer.x, baseStartX + GameOptions.bodies[this.currentBall].size, baseEndX - GameOptions.bodies[this.currentBall].size));
                    })
                })   
            }
        }, this);

        // when the player moves the input
        this.input.on('pointermove', (pointer : Phaser.Input.Pointer) => {
            if (this.currentState == gameState.MOVING) {
                this.dummyBalls[this.currentBall].setX(Phaser.Math.Clamp(pointer.x, baseStartX + GameOptions.bodies[this.currentBall].size, baseEndX - GameOptions.bodies[this.currentBall].size));
            }
        })

        // this is the collision listener used to process contacts
        this.world.on('pre-solve', (contact : Planck.Contact) => {

            // get both bodies user data
            const userDataA : any = contact.getFixtureA().getBody().getUserData();
            const userDataB : any = contact.getFixtureB().getBody().getUserData();

            // get the contact point
            const worldManifold : Planck.WorldManifold = contact.getWorldManifold(null) as Planck.WorldManifold;
            const contactPoint : Planck.Vec2Value = worldManifold.points[0] as Planck.Vec2Value;

            // three nested "if" just to improve readability, to check for a collision we need:
            // 1 - both bodies must be balls
            if (userDataA.type == bodyType.BALL && userDataB.type == bodyType.BALL) {
                
                // 2 - both balls must have the same value
                if (userDataA.value == userDataB.value) {

                    // 3 - balls ids must not be already present in the array of ids 
                    if (this.ids.indexOf(userDataA.id) == -1 && this.ids.indexOf(userDataB.id) == -1) {
                        
                        // add bodies ids to ids array
                        this.ids.push(userDataA.id)
                        this.ids.push(userDataB.id)

                        // add a contact management item with both bodies to remove, the contact point, the new value of the ball and both ids
                        this.contactManagement.push({
                            body1 : contact.getFixtureA().getBody(),
                            body2 : contact.getFixtureB().getBody(),
                            point : contactPoint,
                            value : userDataA.value + 1,
                            id1 : userDataA.id,
                            id2 : userDataB.id
                        })
                    }
                }  
            }
        });
    }

    // method to build emitters
    buildEmitters() : void {

        // loop through each ball
        GameOptions.bodies.forEach((body : any, index : number) => {

            // build particle graphics as a graphic object turned into a sprite
            const particleGraphics : Phaser.GameObjects.Graphics = this.make.graphics({
                x : 0,
                y : 0
            }, false);
            particleGraphics.fillStyle(0xffffff);
            particleGraphics.fillCircle(body.particleSize, body.particleSize, body.particleSize);
            particleGraphics.generateTexture('particle_' + index.toString(), body.particleSize * 2, body.particleSize * 2);

            // create the emitter
            let emitter : Phaser.GameObjects.Particles.ParticleEmitter = this.add.particles(0, 0, 'particle_' + index.toString(), {
                lifespan : 500,
                speed : {
                    min : 0, 
                    max : 50
                },
                scale : {
                    start : 1,
                    end : 0
                },
                emitting : false
            });

            // set the emitter zone as the circle area
            emitter.addEmitZone({
                source : new Phaser.Geom.Circle(0, 0, body.size),
                type : 'random',
                quantity : 1
            });

            // set emitter z-order to 1, to always bring explosions on top
            emitter.setDepth(1);

            // add the emitter to emitters array
            this.emitters.push(emitter)    
        })
    }

    // method to create a physics ball
    createBall(posX : number, posY : number, value : number) : void {

        // add ball sprite
        const ballSprite : Phaser.GameObjects.Sprite = this.add.sprite(posX, posY, 'ball');
        ballSprite.setDisplaySize(GameOptions.bodies[value].size * 2, GameOptions.bodies[value].size  * 2);
        ballSprite.setTint(GameOptions.bodies[value].color)

        // add face sprite
        const faceFrame : number = Phaser.Math.Between(0, 8);
        const face : Phaser.GameObjects.Sprite = this.add.sprite(posX, posY, 'faces', faceFrame)
        
        // create a dynamic body
        const ball : Planck.Body = this.world.createDynamicBody({
            position : new Planck.Vec2(toMeters(posX), toMeters(posY))
        });

        // attach a fixture to the body
        ball.createFixture({
            shape : new Circle(toMeters(GameOptions.bodies[value].size)),
            density : 1,
            friction : 0.3,
            restitution : 0.1
        });

        // set some custom user data
        ball.setUserData({
            sprite : ballSprite,
            type : bodyType.BALL,
            value : value,
            id : this.ballsAdded,
            face : face
        })

        // keep counting how many balls we added so far
        this.ballsAdded ++;
    }

    // method to create a physics polygon
    createPolygon(startX : number, startY : number, endX : number, endY : number) : void {
        
        // create a static body
        const walls : Planck.Body = this.world.createBody({
            position : new Planck.Vec2(toMeters(0), toMeters(0))
        });

        // attach a fixture to the body
        walls.createFixture(Planck.Chain([Planck.Vec2(toMeters(startX), toMeters(endY)), Planck.Vec2(toMeters(startX), toMeters(startY)), Planck.Vec2(toMeters(endX), toMeters(startY)), Planck.Vec2(toMeters(endX), toMeters(endY))]));
        
        // set some custom user data
        walls.setUserData({
            type : bodyType.WALL
        })
    }
   
    // method to destroy a ball
    destroyBall(ball : Planck.Body) : void {

        // get ball user data
        const userData : any = ball.getUserData();

        // destroy the sprites
        userData.sprite.destroy(true);
        userData.face.destroy(true);
      
        // destroy the physics body
        this.world.destroyBody(ball);
        
        // remove body id from ids array
        this.ids.splice(this.ids.indexOf(userData.id), 1);    
    } 

    updateScore(n : number) : void {
        this.score += GameOptions.bodies[n].score;
        this.scoreText.setText(this.score.toString());
    }

    // method to be executed at each frame
    update(totalTime : number, deltaTime : number) : void {  
        
        // advance world simulation
        this.world.step(deltaTime / 1000, 10, 8);
        this.world.clearForces();

        // os there any contact to manage?
        if (this.contactManagement.length > 0) {

            // loop through all contacts
            this.contactManagement.forEach((contact : any) => {

                // set the emitters to explode
                this.emitters[contact.value - 1].explode(50 * contact.value,toPixels(contact.body1.getPosition().x), toPixels(contact.body1.getPosition().y));
                this.emitters[contact.value - 1].explode(50 * contact.value,toPixels(contact.body2.getPosition().x), toPixels(contact.body2.getPosition().y));

                // destroy the balls after some delay, useful to display explosions or whatever
                this.time.addEvent({
                    delay : 10,
                    callback : (() => {
                        this.updateScore(contact.value - 1);
                        this.destroyBall(contact.body1);
                        this.destroyBall(contact.body2);
                    })
                })
                
                // determining blast radius, which is actually a square, but who cares?
                const query : Planck.AABB = new Planck.AABB(
                    new Planck.Vec2(contact.point.x - toMeters(GameOptions.blastRadius), contact.point.y - toMeters(GameOptions.blastRadius)),
                    new Planck.Vec2(contact.point.x + toMeters(GameOptions.blastRadius), contact.point.y + toMeters(GameOptions.blastRadius))
                );

                // query the world for fixtures inside the square, aka "radius"
                this.world.queryAABB(query, function(fixture : Planck.Fixture) {
                    const body : Planck.Body = fixture.getBody();
                    const bodyPosition : Planck.Vec2 = body.getPosition();
                    
                    // just in case you need the body distance from the center of the blast. I am not using it.
                    const bodyDistance : number = Math.sqrt(Math.pow(bodyPosition.x - contact.point.x, 2) + Math.pow(bodyPosition.y - contact.point.y, 2));
                    const angle : number = Math.atan2(bodyPosition.y - contact.point.y, bodyPosition.x - contact.point.x);
                    
                    // the explosion effect itself is just a linear velocity applied to bodies
                    body.setLinearVelocity(new Planck.Vec2(GameOptions.blastImpulse * Math.cos(angle), GameOptions.blastImpulse * Math.sin(angle)));
                    
                    // true = keep querying the world
                    return true;
                });

                // little delay before creating next ball, be used for a spawn animation
                this.time.addEvent({
                    delay: 200,
                    callback: (() => {
                        console.log(contact.value);
                        if (contact.value < GameOptions.bodies.length) {
                            this.createBall(toPixels(contact.point.x), toPixels(contact.point.y), contact.value); 
                        }
                    })
                })       
            })
            this.contactManagement = [];
        }

        // loop through all bodies
        for (let body : Planck.Body = this.world.getBodyList() as Planck.Body; body; body = body.getNext() as Planck.Body) {
            
            // get body user data
            const userData : any = body.getUserData();

            // is it a ball?
            if (userData.type == bodyType.BALL) {

                // get body position
                const bodyPosition : Planck.Vec2 = body.getPosition();

                // get body angle
                const bodyAngle : number = body.getAngle();

                // update sprite position and rotation accordingly
                userData.sprite.setPosition(toPixels(bodyPosition.x), toPixels(bodyPosition.y));
                userData.sprite.setRotation(bodyAngle);
                userData.face.setPosition(toPixels(bodyPosition.x), toPixels(bodyPosition.y));
                userData.face.setRotation(bodyAngle);

                // if a ball fall off the screen...
                if (this.game.config.height as number < userData.sprite.y && this.currentState != gameState.GAMEOVER) {
                    
                    // ... it's game over
                    this.currentState = gameState.GAMEOVER;

                    // set dummy ball to invisible
                    this.dummyBalls[this.currentBall].setVisible(false);

                    // remove all balls with a timer event
                    const gameOverTimer : Phaser.Time.TimerEvent = this.time.addEvent({
                        delay : 100,
                        callback : ((event : Phaser.Time.TimerEvent) => {
                            let body : Planck.Body = this.world.getBodyList() as Planck.Body;
                            const userData : any = body.getUserData();
                            if (userData.type == bodyType.BALL) {
                                this.destroyBall(body)
                            }
                            else {
                                gameOverTimer.remove();

                                // restart game scene
                                this.scene.start('PlayGame')    
                            }
                        }),
                        loop : true
                    })   
                }            
            }
        }
    }  
}

planckUtils.ts

Useful functions to be used in Planck, just to convert pixels to meters and meters to pixels.

TypeScript
// PLANCK UTILITIES

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;
}

Next time you’ll see even more features, meanwhile download the source code of the entire project.

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