Get the full commented source code of

HTML5 Suika Watermelon Game

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

In this second step of the Teeter (Up) series, it’s time to add deadly holes and the goal.

Since both holes and goal are circles which do not affect ball movement, there’s no need to add them as Box2D physics objects, and I am going to check if the ball falls in a hole or reaches the goal simply checking for the distance.

I also added a background using a pattern you can find on Kenney’s site.

Let’s see the prototype in action:

Tap left and right to raise the bar. Don’t make the ball fall off the screen or inside the holes. Try to reach the goal.

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.

Here is the completely commented source code, as you can see the game is very easy to build.

index.html

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

HTML
<!DOCTYPE html>

<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, 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
/* remove margin and padding from all elements */
* {
    padding : 0;
    margin : 0;
}

/* set body background color */
body {
    background-color : #000000;    
}

/* Disable browser handling of all panning and zooming gestures. */
canvas {
    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 = {

    // game canvas size and color
    game : {

        // width of the game, in pixels
        width : 1080,

        // height of the game, in pixels
        height : 1920,

        // game background color
        bgColor : 0x444444
    },

    // Box2D related options
    Box2D : {

        // gravity, in meters per second squared
        gravity : 10,

        // pixels per meters
        worldScale : 120
    },

    // bar properties
    bar : {

        // bar width, in pixels
        width : 1000,

        // bar height, in pixels
        height : 40,

        // bar Y start coordinate, in pixels
        startY : 1800,

        // rotation speed, in degrees per second
        speed : 45
    },

    // ball radius, in pixels
    ballRadius : 25,

    // goal to be reached, with position and diameter
    goal : {
        x : 540,
        y : 200,
        diameter : 256
    },

    // deadly holes, with position and diameter
    holes : [{
        x : 730,
        y : 600,
        diameter : 150
    },
    {
        x : 350,
        y : 600,
        diameter : 150
    },
    {
        x : 730,
        y : 1400,
        diameter : 150
    },
    {
        x : 350,
        y : 1400,
        diameter : 150
    },
    {
        x : 880,
        y : 1000,
        diameter : 100
    },
    {
        x : 200,
        y : 1000,
        diameter : 100
    },{
        x : 540,
        y : 1000,
        diameter : 200
    }]
}

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 { PreloadAssets } from './scenes/preloadAssets';
import { PlayGame } from './scenes/playGame';
import { GameOptions } from './gameOptions';

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

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

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

scenes > 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 images
        this.load.image('ball', 'assets/sprites/ball.png');
        this.load.image('bar', 'assets/sprites/bar.png');
        this.load.image('background', 'assets/sprites/background.png');
        this.load.image('hole', 'assets/sprites/hole.png');
        this.load.image('goal', 'assets/sprites/goal.png');
        this.load.image('flag', 'assets/sprites/flag.png');
    }
  
    // method to be called once the instance has been created
    create() : void {
 
        // call PlayGame scene
        this.scene.start('PlayGame');
    }
}

scenes >playGame.ts

Main game file, all game logic is stored here.

TypeScript
// THE GAME ITSELF

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

// enum to represent bar side
enum barSide {
    LEFT,
    RIGHT,
    NONE
}

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

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

    world : Planck.World;           // the Box2D world  
    liftingSide : barSide;          // the lifting side of the bar
    bar : Planck.Body;              // the bar   
    ball : Planck.Body;             // the ball           

    // method to be called once the instance has been created
    create() : void {

        // add background
        this.add.tileSprite(0, 0, this.game.config.width as number, this.game.config.height as number, 'background').setOrigin(0);

        // when we start playing, no bar side is lifting
        this.liftingSide = barSide.NONE;

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

        // place holes
        GameOptions.holes.forEach((hole : any) => {
            this.add.sprite(hole.x, hole.y, 'hole').setDisplaySize(hole.diameter, hole.diameter);
        });

        // place goal hole
        this.add.sprite(GameOptions.goal.x, GameOptions.goal.y, 'hole').setDisplaySize(GameOptions.goal.diameter, GameOptions.goal.diameter);
        this.add.sprite(GameOptions.goal.x, GameOptions.goal.y, 'goal').setDisplaySize(GameOptions.goal.diameter, GameOptions.goal.diameter);
        this.add.sprite(GameOptions.goal.x, GameOptions.goal.y, 'flag');

        // set bar and ball sprites
        const barSprite : Phaser.GameObjects.TileSprite = this.add.tileSprite(this.game.config.width as number / 2, GameOptions.bar.startY, GameOptions.bar.width, GameOptions.bar.height, 'bar');
        const ballSprite : Phaser.GameObjects.Sprite = this.add.sprite(this.game.config.width as number / 2, GameOptions.bar.startY - GameOptions.bar.height / 2 - GameOptions.ballRadius, 'ball');
        ballSprite.setDisplaySize(GameOptions.ballRadius * 2, GameOptions.ballRadius * 2);

        // create the bar kinematic body
        this.bar = this.world.createKinematicBody({
            position : new Planck.Vec2(toMeters(barSprite.x), toMeters(barSprite.y))
        })

        // attach a fixture to bar body
        this.bar.createFixture({
            shape : new Box(toMeters(GameOptions.bar.width / 2), toMeters(GameOptions.bar.height / 2)),
            density : 1,
            friction : 0,
            restitution : 0
        })

        // set custom bar body user data
        this.bar.setUserData({
            sprite : barSprite
        })

        // create ball dynamic body
        this.ball = this.world.createDynamicBody({
            position : new Planck.Vec2(toMeters(ballSprite.x), toMeters(ballSprite.y))    
        })
        
        // attach a fixture to ball body
        this.ball.createFixture({
            shape : new Circle(toMeters(GameOptions.ballRadius)),
            density : 1,
            friction : 0,
            restitution : 0
        })
        
        // set custom ball body user data
        this.ball.setUserData({
            sprite : ballSprite
        })

        // listener waiting for pointer to be down (pressed)
        this.input.on('pointerdown', (pointer : Phaser.Input.Pointer) => {

            // check lifting side according to pointer x position
            this.liftingSide = Math.floor(pointer.x / (this.game.config.width as number / 2));       
        });

        // listener waiting for pointer to be up (released)
        this.input.on('pointerup', (pointer : Phaser.Input.Pointer) => {

            // bar is not lifting
            this.liftingSide = barSide.NONE;      
        })
    }

    // method to be executed at each frame
    // totalTime : time, in milliseconds, since the game started
    // deltaTime : time, in milliseconds, passed since previous "update" call
    update(totalTime : number, deltaTime : number) : void {  
        
        // advance world simulation
        this.world.step(deltaTime / 1000, 10, 8);
        this.world.clearForces();

        // do we have to lift the bar?
        if (this.liftingSide != barSide.NONE) {

            // determine delta angle
            const deltaAngle : number = Phaser.Math.DegToRad(GameOptions.bar.speed) * deltaTime / 1000;

            // given the angle, determine bar movement
            const barMovement : number = toMeters((GameOptions.bar.width / 2) * Math.sin(deltaAngle));
            
            // get bar position
            const barPosition : Planck.Vec2 = this.bar.getPosition();

            // set new bar angle according to lifting side
            this.bar.setAngle(this.bar.getAngle() + deltaAngle * (this.liftingSide == barSide.LEFT ? 1 : -1));

            // set new bar position
            this.bar.setPosition(new Planck.Vec2(barPosition.x, barPosition.y - barMovement));
        }

        // 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();

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

        // determine ball position
        const ballPosition : Planck.Vec2 = this.ball.getPosition();

        // restart if the ball flies off the screen
        if (ballPosition.x < 0 || toPixels(ballPosition.x) > GameOptions.game.width || toPixels(ballPosition.y) > GameOptions.game.height) {
            this.scene.start('PlayGame');    
        }

        // check if the player is inside a hole
        GameOptions.holes.forEach((hole : any) => {

            // determine distance between ball and hole center
            const distance : number = Phaser.Math.Distance.Between(hole.x, hole.y, toPixels(ballPosition.x), toPixels(ballPosition.y));
            
            // consider the ball to be inside a hole when ball center is inside the hole,
            // this means when the distance is less than hole radius
            if (distance < hole.diameter / 2) {
                this.scene.start('PlayGame');
            }    
        });

        // determine distance between ball and hole center
        const distance : number = Phaser.Math.Distance.Between(GameOptions.goal.x, GameOptions.goal.y, toPixels(ballPosition.x), toPixels(ballPosition.y));
            
        // consider the ball to be inside the goal when ball center is inside the goal,
        // this means when the distance is less than goal radius
        if (distance < GameOptions.goal.diameter / 2) {
            this.scene.start('PlayGame');
        }    
    }  
}

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
// pixels : amount of pixels to convert
export function toMeters(pixels : number) : number {
    return pixels / GameOptions.Box2D.worldScale;
}
 
// simple function to convert meters to pixels
// meters : amount of meters to convert
export function toPixels(meters : number) : number {
    return meters * GameOptions.Box2D.worldScale;
}

The creation of a basic Teeter (Up) prototype was easy, next time I am going to add more levels and scrolling, meanwhile download the source code of the entire project, Node package included.

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