Talking about Ballz game, Game development, HTML5, Javascript, Phaser and TypeScript.
The second game in my 101 games challenge will be a Ballz-like game written in TypeScript and powered by Phaser.
By the way, did you already play the first game? It’s Pushori, a tribute to an old Tony Pa’s Flash game. You can play the game a this link and get the free source code on my Gumroad page.
While I am completing the game, I decided to release the source code of the game engine, for you to play a bit and see how it’s done.
Look at the prototype:
Hold and drag to aim, release to shoot. Collect extra balls and don’t let blocks reach the bottom of the stage, or it’s game over.
Compared with old prototypes, I removed custom object pooling and used Phaser’s killAndHide method, plus another set of improvements.
And here it is the completely commented source code, which consists in one HTML file, one CSS file and seven TypeScript files:
index.html
The web page which hosts the game, to be run inside thegame element.
<!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.
/* 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.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions : any = {
gameSize : {
width : 646, // width of the game, in pixels
height : 960 // height of the game, in pixels
},
gameBackgroundColor : 0x222222, // game background color
ballRadius : 12, // ball radius, in pixels
ballBottomMargin : 100, // margin from the ball and the bottom of the stage, in pixels
ballSpeed : 1000, // ball speed, in pixels per second
blocksPerLine : 7, // amount of blocks per line
blockSpacing : 2, // spacing between blocks, in pixels
blockLines : 8, // amount of lines in game field
maxBlocksPerLine : 6, // maximum amount of blocks per line
singleSizeProbability : 80, // probability of having a single sized block
doubleSizeProbability : 15, // probability of having a double sized block
quadSizeProbability : 5, // probability of having a quadruple sized block
extraBallProbability : 80, // probability of having an extra ball in each row
sizeMultiplier : [1, 3, 5], // block value multiplier, according to block size
colors : [0x2BA4CF, 0x7451C9, 0xF25DA2, 0xFFC30C, 0xFC7417, 0xEC283B, 0x82D523] // possible block colors
}
main.ts
This is where the game is created, with all Phaser related options.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser'; // Phaser
import { GameOptions } from './gameOptions'; // game options
import { PreloadAssets } from './scenes/preloadAssets'; // PreloadAssets scene
import { PlayGame } from './scenes/playGame'; // PlayGame scene
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT, // adjust size to automatically fit in the window
autoCenter : Phaser.Scale.CENTER_BOTH, // center the game horizontally and vertically
parent : 'thegame', // DOM id where to render the game
width : GameOptions.gameSize.width, // game width, in pixels
height : GameOptions.gameSize.height // game height, in pixels
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO, // game renderer
backgroundColor : GameOptions.gameBackgroundColor, // game background color
scale : scaleObject, // scale settings
scene : [ // array with game scenes
PreloadAssets, // PreloadAssets scene
PlayGame // PlayGame scene
],
physics : {
default : 'arcade' // physics engine used is arcade physics
}
}
// the game itself
new Phaser.Game(configObject);
scenes > preloadAssets.ts
Here we preload all assets to be used in the game.
// CLASS TO PRELOAD ASSETS
// PreloadAssets class extends Phaser.Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be called during class preloading
preload() : void {
// load images
this.load.image('ball', 'assets/sprites/ball.png'); // the ball
this.load.image('trajectory', 'assets/sprites/trajectory.png'); // the trajectory
this.load.image('block', 'assets/sprites/block.png'); // the block
this.load.image('aimbase', 'assets/sprites/aimbase.png'); // base of the virtual joystick used to aim
this.load.image('aimstick', 'assets/sprites/aimstick.png'); // top of the virtual joystick used to aim
// load bitmap fonts
this.load.bitmapFont('font', 'assets/fonts/font.png', 'assets/fonts/font.fnt');
}
// method to be executed when the scene is created
create() : void {
// start PlayGame scene
this.scene.start('PlayGame');
}
}
scenes > playGame.ts
Main game file, all game logic is stored here.
// THE GAME ITSELF
// modules to import
import { GameOptions } from '../gameOptions';
import { ArcadeBall} from '../arcadeBall';
import { ArcadeBlock } from '../arcadeBlock';
import { ArcadeExtraBall } from '../arcadeExtraBall';
// enum to define game states
enum gameState {
WAITING, // player is not interacting
AIMING, // player is aiming
READY_TO_FIRE, // player is ready to fire
FIRING // player is firing
}
// enum to define different block types
enum blockType {
NORMAL, // normal block
DOUBLE, // 2x1 block
QUADRUPLE // 2x2 block
}
// enum to define depth level of various game elements
enum depthLevel {
BLOCK = 1, // blocks
BLOCK_TEXT, // block text showing its value
TRAJECTORY, // line to show ball trajectory
VIRTUAL_AIM // virtual joystick
}
// PlayGame class extends Phaser.Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
blockGroup : Phaser.Physics.Arcade.StaticGroup;
ballGroup : Phaser.Physics.Arcade.Group;
extraBallGroup : Phaser.Physics.Arcade.StaticGroup;
coordsLookupTable : Phaser.Math.Vector2[][];
sizeLookupTable : Phaser.Math.Vector2[];
freeSpots : boolean[];
freeUpperSpots : boolean[];
dummyBall : Phaser.GameObjects.Sprite;
// method to be called once the instance has been created
create() : void {
// we start from level 1
let level : number = 1;
// no landed balls at the moment
let landedBalls : number = 0;
// trajectory direction
let direction : number = 0;
let landingX : number = this.game.config.width as number / 2;
// virtual joystick
const aimBase : Phaser.GameObjects.Sprite = this.add.sprite(0, 0, 'aimbase');
aimBase.setVisible(false);
aimBase.setDepth(depthLevel.VIRTUAL_AIM);
const aimStick : Phaser.GameObjects.Sprite = this.add.sprite(0, 0, 'aimstick');
aimStick.setVisible(false);
aimStick.setDepth(depthLevel.VIRTUAL_AIM);
// set all free spots to true
this.freeUpperSpots = [];
this.freeSpots = [];
for (let i : number = 0; i < GameOptions.blocksPerLine; i ++) {
this.freeSpots[i] = true;
this.freeUpperSpots[i] = true;
}
// some constants to adapt window size to game size
const totalHorizontalPadding : number = GameOptions.blockSpacing * (GameOptions.blocksPerLine + 1);
const totalVerticalPadding : number = GameOptions.blockSpacing * (GameOptions.blockLines + 1);
const blockSize : number = (GameOptions.gameSize.width - totalHorizontalPadding) / GameOptions.blocksPerLine;
const blockAndSpacingSize : number = blockSize + GameOptions.blockSpacing;
const fieldHeight : number = totalVerticalPadding + blockSize * GameOptions.blockLines;
const worldStartX : number = (this.game.config.width as number - GameOptions.gameSize.width) / 2;
const worldStartY : number = this.game.config.height as number - fieldHeight - GameOptions.ballBottomMargin;
// actual game field rectangle, the arcade physics world
const fieldRectangle : Phaser.Geom.Rectangle = new Phaser.Geom.Rectangle(worldStartX, worldStartY, GameOptions.gameSize.width, fieldHeight);
// optionalyl draw game field rectangle
this.add.graphics().lineStyle(1, 0xff0000).strokeRectShape(fieldRectangle);
// set physics world
this.physics.world.setBounds(fieldRectangle.left, fieldRectangle.top, fieldRectangle.width, fieldRectangle.height);
// add physics groups
this.ballGroup = this.physics.add.group({
collideWorldBounds : true,
bounceX : 1,
bounceY : 1
});
this.blockGroup = this.physics.add.staticGroup();
this.extraBallGroup = this.physics.add.staticGroup();
// lookup table for blocks coordinates
this.coordsLookupTable = [[], [], []];
for (let i = 0; i <= GameOptions.blocksPerLine; i ++) {
this.coordsLookupTable[blockType.NORMAL][i] = new Phaser.Math.Vector2(fieldRectangle.left + i * blockAndSpacingSize + GameOptions.blockSpacing + blockSize / 2, fieldRectangle.top - blockSize / 2);
this.coordsLookupTable[blockType.DOUBLE][i] = new Phaser.Math.Vector2(fieldRectangle.left + i * blockAndSpacingSize + GameOptions.blockSpacing + blockSize + GameOptions.blockSpacing / 2, fieldRectangle.top - blockSize / 2);
this.coordsLookupTable[blockType.QUADRUPLE][i] = new Phaser.Math.Vector2(fieldRectangle.left + i * blockAndSpacingSize + GameOptions.blockSpacing + blockSize + GameOptions.blockSpacing / 2, fieldRectangle.top - blockSize - GameOptions.blockSpacing / 2);
}
// lookup table for block size coordinates
this.sizeLookupTable = [
new Phaser.Math.Vector2(blockSize, blockSize),
new Phaser.Math.Vector2(blockSize * 2 + GameOptions.blockSpacing, blockSize),
new Phaser.Math.Vector2(blockSize * 2 + GameOptions.blockSpacing, blockSize * 2 + GameOptions.blockSpacing),
]
//this.add.tileSprite(fieldRectangle.left - 1, 0, 8, gameHeight, 'divider').setOrigin(1, 0).setDepth(2);
//this.add.tileSprite(fieldRectangle.right + 1, 0, 8, gameHeight, 'divider').setOrigin(0, 0).setDepth(2);
//this.add.tileSprite(fieldRectangle.left, fieldRectangle.bottom + 1, fieldRectangle.width, GameOptions.ballBottomMargin, 'filler').setOrigin(0).setDepth(2);
//this.add.tileSprite(fieldRectangle.left, 0, fieldRectangle.width, fieldRectangle.top - 1, 'filler').setOrigin(0).setDepth(2);
this.data.set({
state : gameState.WAITING, // at the beginning, game state is MOVING because we want the player to move
movingStuff : 0,
level : 1
});
// add first arcade ball and dummy ball
new ArcadeBall(this, fieldRectangle.centerX, fieldRectangle.bottom - GameOptions.ballRadius, this.ballGroup);
this.dummyBall = this.add.sprite(fieldRectangle.centerX, fieldRectangle.bottom - GameOptions.ballRadius, 'ball');
// trajectory
const trajectory : Phaser.GameObjects.TileSprite = this.add.tileSprite(fieldRectangle.centerX, fieldRectangle.bottom - GameOptions.ballRadius * 2, 12, 480, 'trajectory');
trajectory.setOrigin(0.5, 1);
trajectory.setVisible(false);
trajectory.setDepth(depthLevel.TRAJECTORY);
// triggered when the input is down
this.input.on('pointerdown', (pointer : Phaser.Input.Pointer) => {
if (this.data.get('state') == gameState.WAITING) {
this.data.set('state', gameState.AIMING);
aimBase.setPosition(pointer.x, pointer.y);
aimBase.setVisible(true);
trajectory.setX(this.dummyBall.x);
}
});
// triggered when the input is moved
this.input.on('pointermove', (pointer : Phaser.Input.Pointer) => {
if (this.data.get('state') == gameState.AIMING || this.data.get('state') == gameState.READY_TO_FIRE) {
const distY : number = pointer.y - pointer.downY;
if (distY > 10) {
this.data.set('state', gameState.READY_TO_FIRE);
trajectory.setVisible(true);
aimStick.setVisible(true);
aimStick.setPosition(pointer.x, pointer.y);
direction = Phaser.Math.Angle.Between(pointer.x, pointer.y, pointer.downX, pointer.downY);
trajectory.setRotation(direction + Math.PI / 2);
trajectory.setPosition(this.ballGroup.getFirstAlive().x + GameOptions.ballRadius * 2 * Math.cos(direction), this.ballGroup.getFirstAlive().y + GameOptions.ballRadius * 2 * Math.sin(direction))
}
else {
this.data.set('state', gameState.AIMING);
trajectory.setVisible(false);
}
}
});
// triggered whe the pointer is released
this.input.on('pointerup', () => {
aimBase.setVisible(false);
aimStick.setVisible(false);
trajectory.setVisible(false);
if (this.data.get('state') == gameState.READY_TO_FIRE) {
this.data.set('state', gameState.FIRING)
landedBalls = 0;
this.ballGroup.getChildren().forEach((ball : Phaser.GameObjects.GameObject, index : number) => {
const ballSprite : ArcadeBall = ball as ArcadeBall;
this.time.addEvent({
delay : 100 * index,
callback : () => {
ballSprite.fire(direction, GameOptions.ballSpeed);
if (index == this.ballGroup.countActive() - 1) {
this.dummyBall.setVisible(false);
}
}
})
});
}
else {
if (this.data.get('state') != gameState.FIRING) {
this.data.set('state', gameState.WAITING);
}
}
});
// collision on world bounds
this.physics.world.on('worldbounds', (body : Phaser.Physics.Arcade.Body, up : boolean, down : boolean, left : boolean, right : boolean) => {
if (down && this.data.get('state') == gameState.FIRING) {
body.setVelocity(0);
if (landedBalls == 0) {
landingX = body.center.x;
}
landedBalls ++;
if (landedBalls == this.ballGroup.countActive()) {
this.moveBlocks(blockAndSpacingSize);
this.moveBalls(blockAndSpacingSize, landingX);
}
}
});
// ball Vs group
this.physics.add.collider(this.ballGroup, this.blockGroup, (ball : any, block : any) => {
if (this.data.get('state') == gameState.FIRING) {
block.hit(this.blockGroup);
}
});
// ball Vs extra ball
this.physics.add.overlap(this.ballGroup, this.extraBallGroup, (ball : any, extraBall : any) => {
if (this.data.get('state') == gameState.FIRING) {
extraBall.fallDown(fieldRectangle.bottom - GameOptions.ballRadius);
}
});
this.addBlockLine(level);
this.moveBlocks(blockAndSpacingSize);
this.moveBalls(blockAndSpacingSize, landingX);
}
// method to add a block line
addBlockLine(level : number) : void {
// handle free spots
for (let i : number = 0; i < GameOptions.blocksPerLine; i ++) {
this.freeSpots[i] = this.freeUpperSpots[i];
this.freeUpperSpots[i] = true;
}
// extra ball
const placeExtraBall : boolean = Phaser.Math.RND.integerInRange(1, 100) < GameOptions.extraBallProbability;
if (placeExtraBall) {
const ballPosition : number = Phaser.Math.RND.integerInRange(0, GameOptions.blocksPerLine - 1);
const ballX : number = this.coordsLookupTable[blockType.NORMAL][ballPosition].x;
const ballY : number = this.coordsLookupTable[blockType.NORMAL][ballPosition].y;
if (this.freeSpots[ballPosition]) {
this.freeSpots[ballPosition] = false;
const newExtraBall : ArcadeExtraBall | null = this.extraBallGroup.getFirstDead();
if (newExtraBall == null) {
const ball : ArcadeExtraBall = new ArcadeExtraBall(this, ballX, ballY);
this.extraBallGroup.add(ball);
}
else {
newExtraBall.putInGame(ballX, ballY);
}
}
}
const color : number = Phaser.Math.RND.pick(GameOptions.colors);
const placedBlocks : number[] = [];
for (let i = 0; i < GameOptions.maxBlocksPerLine; i ++) {
const blockPosition : number = Phaser.Math.RND.integerInRange(0, GameOptions.blocksPerLine - 1);
if (placedBlocks.indexOf(blockPosition) == -1 && this.freeSpots[blockPosition]) {
let type : blockType = blockType.NORMAL;
if (blockPosition < GameOptions.blocksPerLine - 1 && placedBlocks.indexOf(blockPosition + 1) == -1) {
const randomNumber : number = Phaser.Math.RND.integerInRange(1, 100);
if (randomNumber < GameOptions.quadSizeProbability && this.freeSpots[blockPosition + 1]) {
type = blockType.QUADRUPLE;
this.freeUpperSpots[blockPosition] = false;
this.freeUpperSpots[blockPosition + 1] = false;
}
else {
if (randomNumber < GameOptions.doubleSizeProbability && this.freeSpots[blockPosition + 1]) {
type = blockType.DOUBLE;
}
}
}
placedBlocks.push(blockPosition);
if (type == blockType.DOUBLE || type == blockType.QUADRUPLE) {
placedBlocks.push(blockPosition + 1);
}
// place the block
const blockX : number = this.coordsLookupTable[type][blockPosition].x;
const blockY : number = this.coordsLookupTable[type][blockPosition].y;
const newBlock : ArcadeBlock | null = this.blockGroup.getFirstDead();
if (newBlock == null) {
const block = new ArcadeBlock(this, blockX, blockY, this.sizeLookupTable[type], depthLevel.BLOCK, depthLevel.BLOCK_TEXT, level * GameOptions.sizeMultiplier[type], color, this.blockGroup);
}
else {
newBlock.putInGame(blockX, blockY, level, this.sizeLookupTable[type], color);
}
}
}
}
// method to move blocks
moveBlocks(deltaY : number) : void {
this.data.inc('movingStuff');
this.tweens.add({
targets : this.blockGroup.getChildren(),
props : {
y : {
getEnd : (target) => {
return target.y + deltaY;
}
}
},
duration : 500,
ease : Phaser.Math.Easing.Cubic.InOut,
onUpdate : (tween : any, target : any) => {
target.updateText();
},
onComplete : () => {
this.data.inc('movingStuff', -1);
if (this.data.get('movingStuff') == 0) {
this.prepareNewTurn();
}
}
})
}
// method to move balls
moveBalls(deltaY : number, destinationX : number) : void {
this.data.inc('movingStuff');
this.tweens.add({
targets : this.extraBallGroup.getChildren(),
props : {
y : {
getEnd : (target) => {
if (target.collected) {
return target.y
}
return target.y + deltaY;
}
},
x : {
getEnd : (target) => {
if (target.collected) {
return destinationX;
}
else {
return target.x;
}
}
}
},
duration : 500,
ease : Phaser.Math.Easing.Cubic.InOut,
onComplete : () => {
this.data.inc('movingStuff', -1);
if (this.data.get('movingStuff') == 0) {
this.prepareNewTurn();
}
}
})
this.data.inc('movingStuff');
this.tweens.add({
targets : this.ballGroup.getChildren(),
x : destinationX,
duration : 500,
ease : Phaser.Math.Easing.Cubic.InOut,
onComplete : () => {
this.data.inc('movingStuff', -1);
if (this.data.get('movingStuff') == 0) {
this.prepareNewTurn();
}
}
})
}
// method to prepare a new turn
prepareNewTurn() : void {
for (let block of this.blockGroup.getChildren()) {
const actualBlock : ArcadeBlock = block as ArcadeBlock;
if (actualBlock.updateAndCheck(GameOptions.blockLines - 1)) {
this.scene.restart();
break;
}
}
this.data.inc('level');
this.addBlockLine(this.data.get('level'));
this.blockGroup.refresh();
this.data.set('state', gameState.WAITING);
this.extraBallGroup.getChildren().forEach((extraBall : any) => {
if (extraBall.collected && extraBall.active) {
const ball : ArcadeBall = new ArcadeBall(this, extraBall.x, extraBall.y, this.ballGroup);
this.extraBallGroup.killAndHide(extraBall);
}
})
this.extraBallGroup.refresh();
this.ballGroup.getChildren().forEach((ball : any, index : number) => {
if (index == 0) {
this.dummyBall.setX(ball.x);
this.dummyBall.setVisible(true);
}
ball.setVisible(false)
});
}
}
arcadeBall.ts
Custom class to define the ball.
// ArcadeBall class extends Phaser.Physics.Arcade.Sprite
export class ArcadeBall extends Phaser.Physics.Arcade.Sprite {
body : Phaser.Physics.Arcade.Body;
constructor(scene : Phaser.Scene, posX : number, posY : number, group : Phaser.Physics.Arcade.Group) {
super(scene, posX, posY, 'ball');
scene.add.existing(this);
scene.physics.add.existing(this);
this.body.onWorldBounds = true;
group.add(this);
this.setVisible(false);
}
// method to fire the ball
fire(direction : number, velocity : number) : void {
this.body.setVelocity(velocity * Math.cos(direction), velocity * Math.sin(direction));
this.setVisible(true);
}
// method to make the ball land
land() : void {
this.body.setVelocity(0);
}
}
arcadeBlock.ts
Custom class to define the block.
// ArcadeBlock class extends Phaser.GameObjects.NineSlice
export class ArcadeBlock extends Phaser.GameObjects.NineSlice {
body : Phaser.Physics.Arcade.Body;
value : number;
row : number;
valueText : Phaser.GameObjects.BitmapText;
constructor(scene : Phaser.Scene, posX : number, posY : number, size : Phaser.Math.Vector2, depth : number, textDepth : number, level : number, color : number, group : Phaser.Physics.Arcade.StaticGroup) {
super(scene, posX, posY, 'block', 0, size.x, size.y, 10, 10, 10, 10);
scene.add.existing(this);
this.row = -1;
this.value = 1;
this.setDepth(depth);
this.valueText = this.scene.add.bitmapText(posX, posY, 'font', this.value.toString(), 32);
this.valueText.setDepth(textDepth);
this.valueText.setOrigin(0.5);
this.value = level;
this.valueText.setText(level.toString());
this.setTint(color);
group.add(this);
}
// method to be executed each time the block is hit
hit(group : Phaser.Physics.Arcade.StaticGroup) : void {
this.value --;
this.valueText.setText(this.value.toString());
if (this.value == 0) {
this.body.checkCollision.none = true;
group.killAndHide(this);
this.valueText.setVisible(false);
}
}
// method to update text position
updateText() : void {
this.valueText.setPosition(this.x, this.y);
}
// method to put the block back in game
putInGame(x : number, y : number, level : number, size : Phaser.Math.Vector2, color : number) : void {
this.setActive(true);
this.setPosition(x, y);
this.setVisible(true);
this.setTint(color);
this.setSize(size.x, size.y);
this.body.setSize(size.x, size.y);
this.valueText.setVisible(true);
this.valueText.setText(level.toString());
this.value = level;
this.row = -1;
this.updateText();
this.body.checkCollision.none = false;
}
// method to update block row and check if it's game over
updateAndCheck(limit : number) : boolean {
if (!this.active) {
return false;
}
this.row ++;
return this.row == limit;
}
}
arcadeExtraBall.ts
Custom class to define the extra ball.
// ArcadeExtraBall class extends Phaser.GameObjects.Sprite
export class ArcadeExtraBall extends Phaser.GameObjects.Sprite {
collected : boolean;
body : Phaser.Physics.Arcade.Body;
constructor(scene : Phaser.Scene, posX : number, posY : number) {
super(scene, posX, posY, 'ball');
scene.add.existing(this);
this.collected = false;
}
// method to make extra ball fall down, when collected
fallDown(destinationY : number) : void {
if (!this.collected) {
this.body.checkCollision.none = true;
this.collected = true;
this.scene.tweens.add({
targets : this,
y : destinationY,
duration : 500,
ease : Phaser.Math.Easing.Cubic.Out
})
}
}
// method to put the ball back in play
putInGame(x : number, y : number) : void {
this.setActive(true);
this.setPosition(x, y);
this.setVisible(true);
this.collected = false;
this.body.checkCollision.none = false;
}
}
Now you are ready to write your own Ballz game, download the entire project and build something useful out of it.
Don’t know where to start? I have a free guide for you.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.