“Ballz” series has been quite popular when I released it in 2018, and I was about to add predictive trajectory when I figured out it was still coded in Phaser 2.
I wanted to quickly port it into Phaser 3 but it also lacked object pooling and some other cool features, so I decided to rewrite it almost from scratch.
And I also commented every line of source code along the way, so this was quite an hard work but I really hope you will appreciate it.
First things first, let’s have a look at the final result:
Tap/click and drag to the bottom to aim the ball, release to launch it.
Get extra balls to activate multiball mode, and don’t let blocks touch the ground or it’s game over.
A lot of Phaser features have been used in the making of this prototype: Arcade physics – and the ball is actually a square – to manage the game engine, tween to make blocks and extra balls move, and actions to, well, execute actions on all children of a physics group.
The source code is quite big, but it’s completely commented and you are free to ask questions:
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 } 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("trajectory", "trajectory.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 sprite this.addTrajectory(); // 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) } } } } } // 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; } // method to get the ball position getBallPosition() { // select gallGroup children let children = this.ballGroup.getChildren(); // return x and y properties of first child return { x: children[0].x, y: children[0].y } } // method to add the trajectory sprite addTrajectory() { // get ball position let ballPosition = this.getBallPosition(); // add trajectory sprite this.trajectory = this.add.sprite(ballPosition.x, ballPosition.y, "trajectory"); // set registration point to bottom center this.trajectory.setOrigin(0.5, 1); // hide sprite this.trajectory.setVisible(false); } // 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; // place trajectory sprite over the ball this.trajectory.x = this.getBallPosition().x; this.trajectory.y = this.getBallPosition().y; } } // 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; // show trajectory sprite this.trajectory.setVisible(true); // determine dragging direction this.direction = Phaser.Math.Angle.Between(e.x, e.y, e.downX, e.downY); // rotate trajectory sprite accordingly this.trajectory.angle = Phaser.Math.RadToDeg(this.direction) + 90; } // 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 sprite this.trajectory.setVisible(false); } } } // method to shoot the ball shootBall() { // is the player aiming? if(this.gameState == PLAYER_IS_AIMING) { // hide trajectory sprite this.trajectory.setVisible(false); // 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; // adjust angle of fire let angleOfFire = Phaser.Math.DegToRad(this.trajectory.angle - 90); // 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, callback: function() { // set ball velocity ball.body.setVelocity(gameOptions.ballSpeed * Math.cos(angleOfFire), gameOptions.ballSpeed * Math.sin(angleOfFire)); } }); }.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); } }
Really a lot of lines, and still no predictive trajectory, but it will be added in next step, meanwhile download the source code.