Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Ballz game, Game development, HTML5, Javascript and Phaser.

Are you enjoying “Ballz series“? I really wanted to add a predictive trajectory, but it wasn’t that easy. Unlike Trick Shot which uses Box2D allowing you to play with time steps, Arcade physics does not feature any time step so you can’t fast forward the simulation.

But I found another solution, given the simplicity of the physics world we are dealing with given that:

1 – we only have squares, balls included, because a tiny ball can be approximated with a square.

2 – None of the bodies rotate. There isn’t rotation at all.

With these two points in mind, it’s quite easy to compute a predictive trajectory, following these steps:

1 – Break down each square into segments. If we have twenty squares in game, we will have 20 squares * 4 segments each + 4 segments representing world bounding box = 84 segments. A really tiny number, for a really dangerous situation. With 20 squares in game, you’re next to game over.

2 – Break down ball square into vertices, so you will have four vertices.

3 – Starting from each vertex, build a line according to player angle of fire.

4 – Check for intersection between each vertext line and each segment.

5 – The intersection – if any – with the shortest distance from its vertex is the collision point

6 – Knowing the vertex which collided, it’s easy to predict where the ball will hit a wall or a block, as well as to calculate the rebound.

End of the boring theory, now have a look:

Tap/click and drag to the bottom to aim the ball, release to launch it.

Green line represents the shortest line from a vertex to a segment, and red square is the collision point.

Have a look at the completely commented source code, this is quite big:

let game;
let gameOptions = {

    // ball size, compared to game width
    ballSize: 0.04,

    // ball speed, in pixels per second
    ballSpeed: 1000,

    // blocks per line, or block columns :)
    blocksPerLine: 7,

    // block lines
    blockLines: 8,

    // max amount of blocks per line
    maxBlocksPerLine: 4,

    // probability 0 -> 100 of having an extra ball in each line
    extraBallProbability: 60,

    // predictive trajectory length, in pixels
    trajectoryLength: 1200
}
window.onload = function() {
    let gameConfig = {
        type: Phaser.AUTO,
        backgroundColor:0x444444,
        scale: {
            mode: Phaser.Scale.FIT,
            autoCenter: Phaser.Scale.CENTER_BOTH,
            parent: "thegame",
            width: 640,
            height: 960
        },
        physics: {
            default: "arcade"
        },
        scene: playGame
    }
    game = new Phaser.Game(gameConfig);
    window.focus();
}

// game states
const WAITING_FOR_PLAYER_INPUT = 0;
const PLAYER_IS_AIMING = 1;
const BALLS_ARE_RUNNING = 2;
const ARCADE_PHYSICS_IS_UPDATING = 3;
const PREPARING_FOR_NEXT_MOVE = 4;

class playGame extends Phaser.Scene {
    constructor() {
        super("PlayGame");
    }
    preload() {
        this.load.image("ball", "ball.png");
        this.load.image("panel", "panel.png");
        this.load.image("block", "block.png");
    }
    create() {

        // at the beginning of the game, we wait for player input
        this.gameState = WAITING_FOR_PLAYER_INPUT;

        // it's not game over... yet
        this.gameOver = false;

        // we start from level zero
        this.level = 0;

        // array used to recycle destroyed blocks
        this.recycledBlocks = [];

        // determine block size according to game width and the number of blocks for each line
        this.blockSize = game.config.width / gameOptions.blocksPerLine;

        // determine game field height according to block size and block lines
        this.gameFieldHeight = this.blockSize * gameOptions.blockLines;

        // empty space is the amount of the stage not covered by game field
        this.emptySpace = game.config.height - this.gameFieldHeight;

        // set bounds of the physics world
        this.physics.world.setBounds(0, this.emptySpace / 2, game.config.width, this.gameFieldHeight);

        // creation of physics groups where to place blocks, balls and extra balls
        this.blockGroup = this.physics.add.group();
        this.ballGroup = this.physics.add.group();
        this.extraBallGroup = this.physics.add.group();

        // the upper panel is called scorePanel because probably you'll want to display player score here
        let scorePanel = this.add.sprite(game.config.width / 2, 0, "panel");
        scorePanel.displayWidth = game.config.width;
        scorePanel.displayHeight = this.emptySpace / 2;
        scorePanel.setOrigin(0.5, 0);

        // bottom panel
        this.bottomPanel = this.add.sprite(game.config.width / 2, game.config.height, "panel");
        this.bottomPanel.displayWidth = game.config.width;
        this.bottomPanel.displayHeight = this.emptySpace / 2;
        this.bottomPanel.setOrigin(0.5, 1);

        // determine actual ball size in pixels
        this.ballSize = game.config.width * gameOptions.ballSize;

        // add the first ball
        this.addBall(game.config.width / 2, game.config.height - this.bottomPanel.displayHeight - this.ballSize / 2, false);

        // add the trajectory graphics
        this.trajectoryGraphics = this.add.graphics();

        // add a block line
        this.addBlockLine();

        // input listeners
        this.input.on("pointerdown", this.startAiming, this);
        this.input.on("pointerup", this.shootBall, this);
        this.input.on("pointermove", this.adjustAim, this);

        // lister for collision with world bounds
        this.physics.world.on("worldbounds", this.checkBoundCollision, this);
    }

    // method to add the ball at a given position x, y. The third argument tells us if it's an extra ball
    addBall(x, y, isExtraBall) {

        // ball creation as a child of ballGroup or extraBallGroup
        let ball = isExtraBall ? this.extraBallGroup.create(x, y, "ball") : this.ballGroup.create(x, y, "ball");

        // resize the ball
        ball.displayWidth = this.ballSize;
        ball.displayHeight = this.ballSize;

        // maximum bounce
        ball.body.setBounce(1, 1);

        // if it's an extra ball...
        if(isExtraBall) {

            // set a custom "row" property to 1
            ball.row = 1;

            // set a custom "collected" property to false
            ball.collected = false;
        }

        // if it's not an extra ball...
        else {

            // ball collides with world bounds
            ball.body.collideWorldBounds = true;

            // ball fires a listener when colliding on world bounds
            ball.body.onWorldBounds = true;
        }
    }

    // method to add a block line
    addBlockLine() {

        // increase level number
        this.level ++;

        // array where to store placed blocks positions
        let placedBlocks = [];

        // will we place an extra ball too?
        let placeExtraBall = Phaser.Math.Between(0, 100) < gameOptions.extraBallProbability;

        // execute the block "gameOptions.maxBlocksPerLine" times
        for(let i = 0; i < gameOptions.maxBlocksPerLine; i ++) {

            // random block position
            let blockPosition =  Phaser.Math.Between(0, gameOptions.blocksPerLine - 1);

            // is this block position empty?
            if(placedBlocks.indexOf(blockPosition) == -1) {

                // save this block position
                placedBlocks.push(blockPosition);

                // should we place an extra ball?
                if(placeExtraBall) {

                    // no more extra balls
                    placeExtraBall = false;

                    // add the extra ball
                    this.addBall(blockPosition * this.blockSize + this.blockSize / 2, this.blockSize / 2 + this.emptySpace / 2, true);
                }

                // this time we don't place an extra ball, but a block
                else {

                    // if we don't have any block to recycle...
                    if(this.recycledBlocks.length == 0) {

                        // add a block
                        this.addBlock(blockPosition * this.blockSize + this.blockSize / 2, this.blockSize / 2 + this.emptySpace / 2, false);

                    }
                    else{

                        // recycle a block
                        this.addBlock(blockPosition * this.blockSize + this.blockSize / 2, this.blockSize / 2 + this.emptySpace / 2, true)
                    }
                }
            }
        }

        // here we store all segments where to check for collisions
        this.fieldSegments = [];

        // get physics world bounds
        let boundRectangle = new Phaser.Geom.Rectangle(0, this.emptySpace / 2, game.config.width, this.gameFieldHeight);

        // turn world bounds into segments
        this.addTofieldSegments(boundRectangle);

        // iterate through all blocks
        Phaser.Actions.Call(this.blockGroup.getChildren(), function(block) {

            // get block bounding box
            let rectangle = block.getBounds();

            // turn bounding box into segments
            this.addTofieldSegments(rectangle);
        }, this);
    }

    // method to add a block at a given x,y position. The third argument tells us if the block is recycled
    addBlock(x, y, isRecycled) {

        // block creation as a child of blockGroup
        let block = isRecycled ? this.recycledBlocks.shift() : this.blockGroup.create(x, y, "block");

        // resize the block
        block.displayWidth = this.blockSize;
        block.displayHeight = this.blockSize;

        // custom property to save block value
        block.value = this.level;

        // custom property to save block row
        block.row = 1;

        // if the block is recycled...
        if(isRecycled) {
            block.x = x;
            block.y = y;
            block.text.setText(block.value);
            block.text.x = block.x;
            block.text.y = block.y;
            block.setVisible(true);
            block.text.setVisible(true);
            this.blockGroup.add(block);
        }

        // if the block is not recycled...
        else {
            // text object to show block value
            let text = this.add.text(block.x, block.y, block.value, {
                font: "bold 32px Arial",
                align: "center",
                color: "#000000"
            });
            text.setOrigin(0.5);

            // text object is stored as a block custom property
            block.text = text;
        }

        // block is immovable, does not react to collisions
        block.body.immovable = true;
    }

    // given the center point, return ball vertices
    getBallVertices(p) {
        let halfBallSize = this.ballSize / 2;
        return [
            new Phaser.Geom.Point(p.x - halfBallSize, p.y - halfBallSize),
            new Phaser.Geom.Point(p.x + halfBallSize, p.y - halfBallSize),
            new Phaser.Geom.Point(p.x + halfBallSize, p.y + halfBallSize),
            new Phaser.Geom.Point(p.x - halfBallSize, p.y + halfBallSize)
        ]
    }

    // method to get the ball position
    getBallPosition() {

        // select gallGroup children
        let children = this.ballGroup.getChildren();

        // return x and y properties of first child
        return new Phaser.Geom.Point(children[0].x, children[0].y);
    }

    // method to start aiming
    startAiming() {

        // are we waiting for player input?
        if(this.gameState == WAITING_FOR_PLAYER_INPUT) {

            // the angle of fire is not legal at the moment
            this.legalAngleOfFire = false;

            // change game state because now the player is aiming
            this.gameState = PLAYER_IS_AIMING;
        }
    }

    // method to adjust the aim
    adjustAim(e) {

        // is the player aiming?
        if(this.gameState == PLAYER_IS_AIMING) {

            // determine x and y distance between current and initial input
            let distX = e.x - e.downX;
            let distY = e.y - e.downY;

            // is y distance greater than 10, that is: is the player dragging down?
            if(distY > 10) {

                // this is a legal agne of fire
                this.legalAngleOfFire = true;

                // determine dragging direction
                this.direction = Phaser.Math.Angle.Between(e.x, e.y, e.downX, e.downY);

                // trajectory direction at the moment is the same as future ball direction
                let trajectoryDirection = this.direction;

                // set trajectory length
                let trajectoryLength = gameOptions.trajectoryLength;

                // clear trajectory graphics
                this.trajectoryGraphics.clear();

                // set trajectory graphics line style
                this.trajectoryGraphics.lineStyle(1, 0x00ff00);

                // get ball bounding box vertices
                this.ballVertices = this.getBallVertices(this.getBallPosition());

                // predictive trajectory loop
                do {

                    // here we will store all collision information, starting from the distance, initally set as a very high number
                    let collisionObject = {
                        collisionDistance: 10000
                    }

                    // loop through all ball vertices
                    this.ballVertices.forEach(function(vertex, index) {

                        // determine trajectory line
                        let trajectoryLine = new Phaser.Geom.Line(vertex.x, vertex.y, vertex.x + trajectoryLength * Math.cos(trajectoryDirection), vertex.y + trajectoryLength * Math.sin(trajectoryDirection));

                        // iterate through all field segments
                        Phaser.Actions.Call(this.fieldSegments, function(line) {

                            // create a new temp point outside game field
                            let intersectionPoint = new Phaser.Geom.Point(-1, -1);

                            // assign temp point the valie of the intersection point between trajectory and polygon line, if any
                            Phaser.Geom.Intersects.LineToLine(trajectoryLine, line, intersectionPoint);

                            // if the intersection point is inside the field...
                            if(intersectionPoint.x != -1) {

                                // determine distance between intersection point and vertex
                                let distance = Phaser.Math.Distance.BetweenPoints(intersectionPoint, vertex);

                                // if the distance is less than current collision object distance, but greater than 1, to avoid collision with the line we just checked...
                                if(distance < collisionObject.collisionDistance && distance > 1) {

                                    // update collision object distance
                                    collisionObject.collisionDistance = distance;

                                    // save collision point
                                    collisionObject.collisionPoint = new Phaser.Geom.Point(intersectionPoint.x, intersectionPoint.y);

                                    // save collision angle
                                    collisionObject.collisionAngle = Phaser.Geom.Line.Angle(line);

                                    // save collision line
                                    collisionObject.collisionLine = Phaser.Geom.Line.Clone(line);

                                    // save vertex index
                                    collisionObject.vertexIndex = index;
                                }
                            }
                        }, this);
                    }.bind(this));

                    // if there was a collision point...
                    if(collisionObject.collisionPoint) {
                        // draw a line between the vertex and the collision point
                        this.trajectoryGraphics.lineBetween(this.ballVertices[collisionObject.vertexIndex].x, this.ballVertices[collisionObject.vertexIndex].y, collisionObject.collisionPoint.x, collisionObject.collisionPoint.y);

                        // set trajectoryGraphics fill style
                        this.trajectoryGraphics.fillStyle(0xff0000, 0.5);

                        // squareOrigin will contain the center of the ball, given the collision point
                        let squareOrigin = new Phaser.Geom.Point();

                        // different actions to do according to vertex index
                        switch(collisionObject.vertexIndex) {

                            // top left
                            case 0 :
                                this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x,  collisionObject.collisionPoint.y, this.ballSize, this.ballSize);
                                squareOrigin.x = collisionObject.collisionPoint.x + this.ballSize / 2;
                                squareOrigin.y = collisionObject.collisionPoint.y + this.ballSize / 2;
                                break;

                            // top right
                            case 1 :
                                this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x - this.ballSize,  collisionObject.collisionPoint.y, this.ballSize, this.ballSize);
                                squareOrigin.x = collisionObject.collisionPoint.x - this.ballSize / 2;
                                squareOrigin.y = collisionObject.collisionPoint.y + this.ballSize / 2;
                                break;

                            // bottom right
                            case 2 :
                                this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x - this.ballSize,  collisionObject.collisionPoint.y - this.ballSize, this.ballSize, this.ballSize);
                                squareOrigin.x = collisionObject.collisionPoint.x - this.ballSize / 2;
                                squareOrigin.y = collisionObject.collisionPoint.y - this.ballSize / 2;
                                break;

                            // bottom left
                            case 3 :
                                this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x,  collisionObject.collisionPoint.y - this.ballSize, this.ballSize, this.ballSize);
                                squareOrigin.x = collisionObject.collisionPoint.x + this.ballSize / 2;
                                squareOrigin.y = collisionObject.collisionPoint.y - this.ballSize / 2;
                                break;
                        }

                        // determine  new trajectory direction according to surface angle
                        if(Phaser.Math.RadToDeg(collisionObject.collisionAngle) % 180 == 0) {
                            trajectoryDirection = 2 * Math.PI - trajectoryDirection;
                        }
                        else {
                            trajectoryDirection = Math.PI - trajectoryDirection;
                        }
                        trajectoryDirection = Phaser.Math.Angle.Wrap(trajectoryDirection);

                        // determine new ball vertices
                        this.ballVertices = this.getBallVertices(squareOrigin);
                    }

                    // calculate the lenght of the remaining trajectory
                    trajectoryLength -= collisionObject.collisionDistance;

                // keep looping while trajectory length is greater than zero
                } while (trajectoryLength > 0);
            }

            // y distance is smaller than 10, that is: player is not dragging down
            else{

                // not a legal angle of fire
                this.legalAngleOfFire = false;

                // hide trajectory graphics
                this.trajectoryGraphics.clear();
            }
        }
    }

    // method to turn a rectangle into 4 segments
    addTofieldSegments(rectangle) {
        this.fieldSegments.push(rectangle.getLineA());
        this.fieldSegments.push(rectangle.getLineB());
        this.fieldSegments.push(rectangle.getLineC());
        this.fieldSegments.push(rectangle.getLineD());
    }

    // method to shoot the ball
    shootBall() {

        // is the player aiming?
        if(this.gameState == PLAYER_IS_AIMING) {

            // hide trajectory graphics
            this.trajectoryGraphics.clear();

            // do we have a legal angle of fire?
            if(this.legalAngleOfFire) {

                // change game state
                this.gameState = BALLS_ARE_RUNNING;

                // no balls have landed already
                this.landedBalls = 0;

                // iterate through all balls
                this.ballGroup.getChildren().forEach(function(ball, index) {

                    // add a timer event which fires a ball every 0.1 seconds
                    this.time.addEvent({
                        delay: 100 * index,
                        callbackScope: this,
                        callback: function() {

                            // set ball velocity
                            ball.body.setVelocity(gameOptions.ballSpeed * Math.cos(this.direction), gameOptions.ballSpeed * Math.sin(this.direction));
                        }
                    });
                }.bind(this))
            }

            // we don't have a legal angle of fire
            else {

                // let's wait for player input again
                this.gameState = WAITING_FOR_PLAYER_INPUT;
            }
        }
    }

    // method to check collision between a ball and the bounds
    checkBoundCollision(ball, up, down, left, right) {

        // we only want to check lower bound and only if balls are running
        if(down && this.gameState == BALLS_ARE_RUNNING) {

            // stop the ball
            ball.setVelocity(0);

            // increase the amount of landed balls
            this.landedBalls ++;

            // if this is the first landed ball...
            if(this.landedBalls == 1) {

                // save the ball in firstBallToLand variable
                this.firstBallToLand = ball;
            }
        }
    }

    // method to be executed at each frame
    update() {

        // if Arcade physics is updating or balls are running and all balls have landed...
        if((this.gameState == ARCADE_PHYSICS_IS_UPDATING) || this.gameState == BALLS_ARE_RUNNING && this.landedBalls == this.ballGroup.getChildren().length) {

            // if the game state is still set to BALLS_ARE_RUNNING...
            if(this.gameState == BALLS_ARE_RUNNING) {

                // ... basically wait a frame to let Arcade physics update body positions
                this.gameState = ARCADE_PHYSICS_IS_UPDATING;
            }

            // if Arcade already updated body positions...
            else{

                // time to prepare for next move
                this.gameState = PREPARING_FOR_NEXT_MOVE;

                // move the blocks
                this.moveBlocks();

                // move the balls
                this.moveBalls();

                // move the extra balls
                this.moveExtraBalls();
            }
        }

        // if balls are running...
        if(this.gameState == BALLS_ARE_RUNNING) {

            // handle collisions between balls and blocks
            this.handleBallVsBlock();

            // handle collisions between ball and extra balls
            this.handleBallVsExtra();
        }
    }

    // method to move all blocks down a row
    moveBlocks() {

        // we will move blocks with a tween
        this.tweens.add({

            // we set all blocks as tween target
            targets: this.blockGroup.getChildren(),

            // which properties are we going to tween?
            props: {

                // y property
                y: {

                    // each block is moved down from its position by its display height
                    getEnd: function(target) {
                        return target.y + target.displayHeight;
                    }
                },
            },

            // scope of callback function
            callbackScope: this,

            // each time the tween updates...
            onUpdate: function(tween, target) {

                // tween down the value text too
                target.text.y = target.y;
            },

            // once the tween completes...
            onComplete: function() {

                // wait for player input again
                this.gameState = WAITING_FOR_PLAYER_INPUT;

                // execute an action on all blocks
                Phaser.Actions.Call(this.blockGroup.getChildren(), function(block) {

                    // update row custom property
                    block.row ++;

                    // if a block reached the bottom of the game area...
                    if(block.row == gameOptions.blockLines) {

                        // ... it's game over
                        this.gameOver = true;
                    }
                }, this);

                // if it's not game over...
                if(!this.gameOver) {

                    // add another block line
                    this.addBlockLine();
                }

                // if it's game over...
                else {

                    // ...restart the game
                    this.scene.start("PlayGame");
                }
            },

            // tween duration, 1/2 second
            duration: 500,

            // tween easing
            ease: "Cubic.easeInOut"
        });
    }

    // method to move all balls to first landed ball position
    moveBalls() {

        // we will move balls with a tween
        this.tweens.add({

            // we set all balls as tween target
            targets: this.ballGroup.getChildren(),

            // set x to match the horizontal position of the first landed ball
            x: this.firstBallToLand.gameObject.x,

            // tween duration, 1/2 second
            duration: 500,

            // tween easing
            ease: "Cubic.easeInOut"
        });
    }

    // method to move all extra balls
    moveExtraBalls() {

        // execute an action on all extra balls
        Phaser.Actions.Call(this.extraBallGroup.getChildren(), function(ball) {

            // if a ball reached the bottom of the game field...
            if(ball.row == gameOptions.blockLines) {

                // set it as "collected"
                ball.collected = true;
            }
        })

        // we will move balls with a tween
        this.tweens.add({

            // we set all extra balls as tween target
            targets: this.extraBallGroup.getChildren(),

            // which properties are we going to tween?
            props: {

                // x property
                x: {

                    getEnd: function(target) {

                        // is the ball marked as collected?
                        if(target.collected) {

                            // set x to match the horizontal position of the first landed ball
                            return target.scene.firstBallToLand.gameObject.x;
                        }

                        // ... or leave it in its place
                        return target.x;
                    }
                },

                // same thing with y position
                y: {
                    getEnd: function(target) {
                        if(target.collected) {
                            return target.scene.firstBallToLand.gameObject.y;
                        }
                        return target.y + target.scene.blockSize;
                    }
                },
            },

            // scope of callback function
            callbackScope: this,

            // once the tween completes...
            onComplete: function() {

                // execute an action on all extra balls
                Phaser.Actions.Call(this.extraBallGroup.getChildren(), function(ball) {

                    // if the ball is not collected...
                    if(!ball.collected) {

                        // ... increase its row property
                        ball.row ++;
                    }

                    // if the ball has been collected...
                    else {

                        // remove the ball from extra ball group
                        this.extraBallGroup.remove(ball);

                        // add the ball to ball group
                        this.ballGroup.add(ball);

                        // set extra ball properties
                        ball.body.collideWorldBounds = true;
                        ball.body.onWorldBounds = true;
                        ball.body.setBounce(1, 1);
                    }
                }, this);
            },

            // tween duration, 1/2 second
            duration: 500,

            // tween easing
            ease: "Cubic.easeInOut"
        });
    }

    // method to handle collision between a ball and a block
    handleBallVsBlock() {

        // check collision between ballGroup and blockGroup members
        this.physics.world.collide(this.ballGroup, this.blockGroup, function(ball, block) {

            // decrease block value
            block.value --;

            // if block value reaches zero...
            if(block.value == 0) {

                // push block into recycledBlocks array
                this.recycledBlocks.push(block);

                // remove the block from blockGroup
                this.blockGroup.remove(block);

                // hide the block
                block.visible = false;

                // hide block text
                block.text.visible = false;
            }

            // if block value does not reach zero...
            else{

                // update block text
                block.text.setText(block.value);
            }
        }, null, this);
    }

    // method to handle collision between a ball and an extra ball
    handleBallVsExtra() {

        // check overlap between ballGroup and extraBallGroup members
        this.physics.world.overlap(this.ballGroup, this.extraBallGroup, function(ball, extraBall) {

            // set extra ball as collected
            extraBall.collected = true;

            // add a tween to move the ball down
            this.tweens.add({

                // the target is the extra ball
                targets: extraBall,

                // y destination position is the very bottom of game area
                y: game.config.height - this.bottomPanel.displayHeight - extraBall.displayHeight / 2,

                // tween duration, 0.2 seconds
                duration: 200,

                // tween easing
                ease: "Cubic.easeOut"
            });
        }, null, this);
    }
}

In some rare cases, it won’t be that accurate. Why? Probably because the way I determine the collision point, ready for continuous collision detection, is not the same way Arcade physics handle collisions, in a discrete environment.

With an easy and simple physics world like this one, we could even write the game without any physics engine, but this is an experiment I may consider later on, 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.