Get the full commented source code of

HTML5 Suika Watermelon Game

Talking about Block it game, Game development, HTML5, Javascript, Phaser and TypeScript.

I was sure was able to handle Block It physics without using any physics engine, and now that’s it.

Not only I am not using Arcade Physics, but I also work with rotating walls which would require Box2D and bullets to work.

And I only used the script about continuous collision detection between a moving circle and one or more static line segments.

Actually, a simplified version of the script since I do not check for vertices collisions. Useless? Did I need to reinvent the wheel? Maybe yes, maybe not, but it’s always interesting to see how you can achieve simple results without using third party libraries, even if you end up by using one of them.

Look at the game:

Tap or click to start and to activate the upper and lower walls at the right time, to make the ball bounce. If the ball flies off the screen, it’s game over. Look how walls rotate.

The black lines you see once you start the game are the lines used for the collision detection.

The game is split into one html file, one css file and 10 TypeScript files, not every file has been commented yet because I need to complete and optimize the game, anyway here is the source code:

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">
        <link rel="stylesheet" href="style.css">
        </style>
        <script src="main.js"></script>
    </head>
    <body>
        <div id="thegame"></div>
    </body>
</html>

style.css

The cascading style sheets of the main web page.

* {
    padding : 0;
    margin : 0;
}

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.

// CONFIGURABLE GAME OPTIONS

export const GameOptions = {

    // duration of the wall, in milliseconds
    wallDuration : 100,
 
    // ball start speed, in pixels/second
    ballStartSpeed : 500,
 
    // ball speed increase at each successful bounce, in pixels/second
    ballSpeedIncrease : 5,

    // ball radius, in pixels
    ballRadius : 25,

    // wall thickness, in pixels
    wallSize : 16,

    // wall padding from game canvas, in pixels
    wallPadding : 60,

    // wall cooldown, in milliseconds
    coolDown : 150,

    // extra wall width, to make wall longer than they are
    extraWallWidth : 200,

    // wall rotation range, in degrees
    wallRotationRange : [-20, 20]
}

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 : 480,
    height : 640
}

// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
    type : Phaser.AUTO,
    backgroundColor : 0xfe5430,
    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 sprites used for the ball and 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 an image
        this.load.image('ball', 'assets/ball.png');
        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 { GameOptions } from './gameOptions';
import WallSprite from './wallSprite';
import BallSprite from './ballSprite';
import { CollisionResult } from './collisionResult';
import { CollisionManagement } from './collisionManagement';
import { wallPosition } from './wallPosition';

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

    // is the game over?
    gameOver : boolean;

    // is the game started?
    gameStarted : boolean;

    // can we activate the wall by clicking/tapping on the canvas?
    canActivateWalls : boolean;
   
    // the ball itself
    theBall : BallSprite;
   
    // game score
    score : number;

    // text object to display the score
    scoreText : Phaser.GameObjects.Text;
  
    // instance to the class to handle collisions
    collisionManagement : CollisionManagement;

    // game walls
    wallArray : WallSprite[];

    // are all walls active? True: all walls are active, false: only left and right walls are active
    allWallsAreActive : boolean;
    
    // graphics objects to render the physics walls, as debug
    simulationGraphics : Phaser.GameObjects.Graphics

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

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

        // create a new collision management
        this.collisionManagement = new CollisionManagement();

        // set start score to zero
        this.score = 0;

        // game is not started yet
        this.gameStarted = false;

        // game is not over
        this.gameOver = false;

        // just a couple of variables to store game width and height
        let gameWidth : number = this.game.config.width as number;
        let gameHeight : number = this.game.config.height as number;

        // add score text
        this.addScoreText(gameWidth / 2, gameHeight / 2);
        
        // add the ball to the game
        this.theBall = new BallSprite(this, gameWidth / 2, gameHeight / 2, GameOptions.ballRadius, GameOptions.ballStartSpeed);
        
        // determine width of horizontal walls
        let wallWidth : number = gameWidth - GameOptions.wallPadding * 2 - GameOptions.wallSize * 2;

        // determine height of vertical walls
        let wallHeight : number = gameHeight - GameOptions.wallPadding * 2;

        // total spacing from canvas border to wall
        let totalSpacing : number = GameOptions.wallPadding + GameOptions.wallSize;

        // these are the four walls
        this.wallArray = [];
        this.wallArray[wallPosition.Up] = new WallSprite(this, totalSpacing - GameOptions.extraWallWidth, GameOptions.wallPadding, wallWidth + 2 * GameOptions.extraWallWidth, GameOptions.wallSize, wallPosition.Up);
        this.wallArray[wallPosition.Right] = new WallSprite(this, gameWidth - totalSpacing, GameOptions.wallPadding - GameOptions.extraWallWidth, GameOptions.wallSize, wallHeight + 2 * GameOptions.extraWallWidth, wallPosition.Right);
        this.wallArray[wallPosition.Down] = new WallSprite(this, totalSpacing - GameOptions.extraWallWidth, gameHeight - totalSpacing, wallWidth + 2 * GameOptions.extraWallWidth, GameOptions.wallSize, wallPosition.Down);   
        this.wallArray[wallPosition.Left] = new WallSprite(this, GameOptions.wallPadding, GameOptions.wallPadding - GameOptions.extraWallWidth, GameOptions.wallSize, wallHeight + 2 * GameOptions.extraWallWidth, wallPosition.Left);  

        // add simulation graphics
        this.simulationGraphics = this.add.graphics();
        
        // add event listener waiting for pointer down
        this.input.on('pointerdown', this.activateWall, this);   
    }

    // method to add the score text
    addScoreText(posX : number, posY : number) : void {
        this.scoreText = this.add.text(posX, posY, '0', {
            fontFamily : 'Arial',
            fontStyle : 'bold',
            fontSize : '192px',
            color : '#ffffff'
        });
        this.scoreText.setOrigin(0.5);
        this.scoreText.setAlpha(0.3);
    }

    // method to activate the walls
    activateWall() : void {

        // has the game started?
        if (!this.gameStarted) {

            // the game hasn't started yet, so se the ball to a random speed
            this.theBall.setRandomSpeed();

            // turn off the upper and lower walls
            this.wallArray[wallPosition.Up].turnOff();
            this.wallArray[wallPosition.Down].turnOff();

            // all walls aren't active
            this.allWallsAreActive = false;

            // now the game has started
            this.gameStarted = true;

            // now we can activate the walls
            this.canActivateWalls = true;
            return;
        }

        // can we activate the walls?
        if (this.canActivateWalls) {

            // we can't activate walls anymore
            this.canActivateWalls = false;

            // turn on upper and lower walls
            this.wallArray[wallPosition.Up].turnOn();
            this.wallArray[wallPosition.Down].turnOn();

            // all walls are active
            this.allWallsAreActive = true;

            // add a time event
            this.time.addEvent({

                // delay of the event: wall duration
                delay: GameOptions.wallDuration,

                // scope of the event callback function
                callbackScope: this,

                // callback function
                callback : () => {

                    // turn off the walls
                    this.wallArray[wallPosition.Up].turnOff();
                    this.wallArray[wallPosition.Down].turnOff();

                    // all walls aren't active
                    this.allWallsAreActive = false;

                    // add another time event
                    this.time.addEvent({

                        // delay of the event: wall cooldown
                        delay : GameOptions.coolDown,

                        // scope of the event callback function
                        callbackScope : this,

                        // callback function
                        callback : () => {

                            // we can activate walls again
                            this.canActivateWalls = true;
                        }
                    })
                }
            });
        }
    }

    // method to be called at each frame
    update(time : number, deltaTime : number) : void {

        // has the game started?
        if (this.gameStarted) {

            // these are the lines to b checked against collision
            let solidWalls : Phaser.Geom.Line[] = [
                this.wallArray[wallPosition.Up].getCollisionLine(),
                this.wallArray[wallPosition.Right].getCollisionLine(),
                this.wallArray[wallPosition.Down].getCollisionLine(),
                this.wallArray[wallPosition.Left].getCollisionLine()
            ]

            // just some debug graphics showing the collision lines
            this.simulationGraphics.clear();
            this.simulationGraphics.lineStyle(1, 0x000000);
            this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Up]);
            this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Right]);
            this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Left]);
            this.simulationGraphics.strokeLineShape(solidWalls[wallPosition.Down]);

            // collision result between moving circle and static lines
            let collisionResult : CollisionResult[] = this.collisionManagement.checkCollision(this.theBall.circleShape, this.theBall.ballMovementeVector(deltaTime), (this.allWallsAreActive) ? solidWalls : [solidWalls[wallPosition.Right], solidWalls[wallPosition.Left]]);
            
            // final ball destination
            let destinationPoint : Phaser.Geom.Point = collisionResult[collisionResult.length - 1].point;
            
            // update ball position
            this.theBall.updatePosition(destinationPoint);

            // collision result has a length greater than 1, that is, did the ball collide against at least a wall?
            if (collisionResult.length > 1) {

                // increase the score
                this.score += collisionResult.length - 1;

                // show updated score
                this.scoreText.setText(this.score.toString());

                // we get the last normalized velocty, this will be new ball's direction 
                let lastNormalizedVelocity : Phaser.Math.Vector2 = collisionResult[collisionResult.length - 2].velocity.normalize(); 
                
                // update ball velocity
                this.theBall.updateVelocity(lastNormalizedVelocity, GameOptions.ballSpeedIncrease); 

                // loop through all collisions
                collisionResult.forEach((collision : CollisionResult) => {

                    // if the collision index is different than -1 (no collision)
                    if (collision.index != -1) {

                        // set the opposite wall to a random angle
                        this.wallArray[(collision.index + 2) % 4].randomAngle();
                    }
                }) 
            }
        }
        
        // if the ball flies off the canvas and it's not game over yet...
        if ((this.theBall.y > (this.game.config.height as number) || this.theBall.y < 0) && !this.gameOver) {
            
            // now it's game over
            this.gameOver = true;

            // shake the camera
            this.cameras.main.shake(800, 0.05);

            // add a time event
            this.time.addEvent({

                // delay, in milliseconds
                delay: 800,

                // scope of the event callback function
                callbackScope: this,

                // callback function
                callback: () => {

                    // restart "PlayGame" scene
                    this.scene.start('PlayGame');
                }
            });
        }
    }
}

wallSprite.ts

Custom class for the walls, extending Phaser.GameObjects.Sprite.

import { wallPosition, deltaAngles } from './wallPosition';
import { GameOptions } from './gameOptions';

export default class WallSprite extends Phaser.GameObjects.Sprite {

    scene : Phaser.Scene;
    position : wallPosition;

    constructor(scene : Phaser.Scene, posX : number, posY : number, width : number, height : number, position : wallPosition) {
        super(scene, posX + width / 2, posY + height / 2, 'wall');
        this.setDisplaySize(width, height);
        this.randomAngle();
        scene.add.existing(this);
        this.scene = scene;
        this.position = position;
    }

    turnOff() : void {
        this.setAlpha(0.1);
    }

    turnOn() : void {
        this.setAlpha(1);
    }

    randomAngle() : void {
        let newAngle : number = Phaser.Math.Between(GameOptions.wallRotationRange[0] * 10, GameOptions.wallRotationRange[1] * 10) / 10;
        this.scene.tweens.add({
            targets : this,
            angle : newAngle,
            delay: 100
        })
    }

    getCollisionLine() : Phaser.Geom.Line {
        let halfWallSize : number = GameOptions.wallSize / 2;
        let lineWidth : number = (this.position == wallPosition.Up || this.position == wallPosition.Down) ? this.displayWidth : this.displayHeight;
        let startAngle : number = this.rotation + deltaAngles[this.position];
        let directionAngle : number = this.rotation + ((this.position == wallPosition.Up || this.position == wallPosition.Down) ? 0 : Math.PI / 2);
        return new Phaser.Geom.Line(
            this.x + halfWallSize * Math.cos(startAngle) - lineWidth * Math.cos(directionAngle),
            this.y + halfWallSize * Math.sin(startAngle) - lineWidth * Math.sin(directionAngle),
            this.x + halfWallSize * Math.cos(startAngle) + lineWidth * Math.cos(directionAngle),
            this.y + halfWallSize * Math.sin(startAngle) + lineWidth * Math.sin(directionAngle)
        )
    }
}

wallPosition.ts

Just a custom enum to have a better readability when handling with wall position, and some delta angles to rotate walls according to their position.

export enum wallPosition {
    Up,
    Right,
    Down,
    Left
}

export const deltaAngles : number[] = [Math.PI / 2, - Math.PI, - Math.PI / 2, 0];

ballSprite.ts

Custom class for the ball, extending Phaser.GameObjects.Sprite.

export default class ballSprite extends Phaser.GameObjects.Sprite {

    velocity : Phaser.Math.Vector2;
    circleShape : Phaser.Geom.Circle;
    speed : number;

    constructor(scene : Phaser.Scene, posX : number, posY : number, radius : number, speed : number) {
        super(scene, posX, posY, 'ball');
        scene.add.existing(this);
        let ballSize : number = radius * 2
        this.setDisplaySize(ballSize, ballSize);
        this.circleShape = new Phaser.Geom.Circle(posX, posY, radius);
        this.speed = speed;
    }

    setRandomSpeed() : void {
        let randomAngle : number = Phaser.Math.Angle.Random();
        this.velocity = new Phaser.Math.Vector2(this.speed * Math.cos(randomAngle), this.speed * Math.sin(randomAngle));
    }

    ballMovementeVector(time : number) : Phaser.Math.Vector2 {
        let milliseconds : number = time / 1000;
        return new Phaser.Math.Vector2(this.velocity.x * milliseconds, this.velocity.y * milliseconds);  
    }

    updatePosition(point : Phaser.Geom.Point) : void {
        this.setPosition(point.x, point.y);
        this.circleShape.setPosition(point.x, point.y);
    }

    updateVelocity(normal : Phaser.Math.Vector2, speed : number) : void {
        this.speed += speed;
        this.velocity.setTo(this.speed * normal.x, this.speed * normal.y);
    }
}

collisionManagement.ts

Class to handle the collision, mostly the same seen and explained in this post.

import { intersectionType, Intersection } from './intersection';
import { CollisionResult } from './collisionResult';

export class CollisionManagement {

    getIntersectionPoint(line1 : Phaser.Geom.Line, line2 : Phaser.Geom.Line) : Intersection { 
        if ((line1.x1 == line1.x2 && line1.y1 == line1.y2) || (line2.x1 == line2.x2 && line2.y1 == line2.y2)) {
            return new Intersection(intersectionType.None);
        }
        let denominator : number = ((line2.y2 - line2.y1) * (line1.x2 - line1.x1) - (line2.x2 - line2.x1) * (line1.y2 - line1.y1));
        if (denominator == 0) {
            return new Intersection(intersectionType.None);
        }
        let ua : number = ((line2.x2 - line2.x1) * (line1.y1 - line2.y1) - (line2.y2 - line2.y1) * (line1.x1 - line2.x1)) / denominator;
        let ub : number = ((line1.x2 - line1.x1) * (line1.y1 - line2.y1) - (line1.y2 - line1.y1) * (line1.x1 - line2.x1)) / denominator;
        let outsideSegments : boolean = (ua < 0 || ua > 1 || ub < 0 || ub > 1)
        let x : number = line1.x1 + ua * (line1.x2 - line1.x1);
        let y : number = line1.y1 + ua * (line1.y2 - line1.y1);
        return new Intersection(outsideSegments ? intersectionType.Simple : intersectionType.Strict, new Phaser.Geom.Point(x, y));
    }
    
    checkCollision(movingCircle : Phaser.Geom.Circle, circleVelocity : Phaser.Math.Vector2, staticLines : Phaser.Geom.Line[]) : CollisionResult[] {
        let velocityLine : Phaser.Geom.Line = new Phaser.Geom.Line(movingCircle.x, movingCircle.y, movingCircle.x + circleVelocity.x, movingCircle.y + circleVelocity.y);
        let gotCollision : boolean = false;
        let closestCircleDistance : number = Infinity;
        let collisionResult : CollisionResult = new CollisionResult(new Phaser.Geom.Point(movingCircle.x + circleVelocity.x, movingCircle.y + circleVelocity.y), new Phaser.Math.Vector2(0, 0), -1);
        staticLines.forEach((staticLine : Phaser.Geom.Line, index : number) => {
            let extendedLine : Phaser.Geom.Line =  Phaser.Geom.Line.Clone(staticLine);
            Phaser.Geom.Line.Extend(extendedLine, movingCircle.radius);
            let velocityToSegmentIntersection : Intersection = this.getIntersectionPoint(velocityLine, extendedLine);
            let destinationCircle : Phaser.Geom.Circle = new Phaser.Geom.Circle(velocityLine.x2, velocityLine.y2, movingCircle.radius);
            let destinationCircleIntersectsBarrier : boolean = Phaser.Geom.Intersects.LineToCircle(staticLine, destinationCircle);
            if (velocityToSegmentIntersection.type == intersectionType.Strict || destinationCircleIntersectsBarrier) {
                let shortestDistancePoint : Phaser.Geom.Point = Phaser.Geom.Line.GetNearestPoint(staticLine, new Phaser.Geom.Point(movingCircle.x, movingCircle.y));
                let shortestDistanceLine : Phaser.Geom.Line = new Phaser.Geom.Line(movingCircle.x, movingCircle.y, shortestDistancePoint.x, shortestDistancePoint.y);
                let shortestDistanceLineLength : number = Phaser.Geom.Line.Length(shortestDistanceLine);
                let movementLine : Phaser.Geom.Line = new Phaser.Geom.Line(movingCircle.x, movingCircle.y, velocityToSegmentIntersection.point.x, velocityToSegmentIntersection.point.y);
                let ratioonmovement : number =  movingCircle.radius / shortestDistanceLineLength;
                let newCenter: Phaser.Geom.Point = Phaser.Geom.Line.GetPoint(movementLine, 1 - ratioonmovement);
                let closestPoint : Phaser.Geom.Point = Phaser.Geom.Line.GetNearestPoint(staticLine, new Phaser.Geom.Point(newCenter.x, newCenter.y))
                let distanceFromNewCenterToCircle : number = Phaser.Math.Distance.Between(movingCircle.x, movingCircle.y, newCenter.x, newCenter.y); 
                if (closestPoint.x >= staticLine.left && closestPoint.x <= staticLine.right && closestPoint.y >= staticLine.top && closestPoint.y <= staticLine.bottom && distanceFromNewCenterToCircle < closestCircleDistance) {       
                    gotCollision = true;
                    closestCircleDistance = distanceFromNewCenterToCircle;
                    let reflectionAngle : number = Phaser.Geom.Line.ReflectAngle(velocityLine, staticLine);
                    let remainingVelocity : number = Phaser.Math.Distance.Between(newCenter.x, newCenter.y, velocityLine.x2, velocityLine.y2);
                    let finalIndex = staticLines.length == 4 ? index : 1 + 2 * index;
                    collisionResult = new CollisionResult(new Phaser.Geom.Point(newCenter.x, newCenter.y), new Phaser.Math.Vector2(remainingVelocity * Math.cos(reflectionAngle), remainingVelocity * Math.sin(reflectionAngle)), finalIndex);  
                }
            }
        })
        if (!gotCollision) {
            return [collisionResult];
        }
        else {
            return [collisionResult].concat(this.checkCollision(new Phaser.Geom.Circle(collisionResult.point.x, collisionResult.point.y, movingCircle.radius), new Phaser.Math.Vector2(collisionResult.velocity.x, collisionResult.velocity.y), staticLines));    
        }
    }
}

collisionResult.ts

Just a custom class to handle a collision, which is made by a point (the new center of the circle), a vector (the new circle velocity), and a reference to the line which collided.

export class CollisionResult {
    point : Phaser.Geom.Point;
    velocity : Phaser.Math.Vector2;
    index : number;
    constructor(point : Phaser.Geom.Point, velocity : Phaser.Math.Vector2, index : number) {
        this.point = point;
        this.velocity = velocity;
        this.index = index;
    }
}

intersection.ts

Another custom class to define the intersection type of two line segments, which can be None if line segments do not intersect, Simple if they would intersect if they were infinite or Strict if they intersect.

export enum intersectionType {
    None,
    Simple,
    Strict
}
 
export class Intersection {   
    type : intersectionType;
    point : Phaser.Geom.Point;
    constructor(type : intersectionType, point? : Phaser.Geom.Point) {
        this.type = type;
        if (point !== undefined) {
            this.point = point;
        }
    }
}

And that’s it. Interested in seeing a Box2D version? I’ll build it next week. Stay tuned, and meanwhile download the source code.

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