Talking about Ballz game, Game development, HTML5, Javascript and Phaser.
“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.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.