Talking about Game development, HTML5, Javascript and Phaser.
Three years ago I deleveloped a math game called DrawSum for a learning project.
It was a mix between a draw and match game based upon Dungeon Raid engine and a pure math game.
I used Phaser 2.4.8, but it’s perfectly compatible with latest Phaser CE release and you can easily port to Phaser 3 or any other language if you prefer (I am about do it).
Have a look at the game:
As the title suggests, you have to draw to connect tiles and match the numbers above before time runs out.
Why am I releasing a code which is more than three years old?
First, because source code never gets old. Sure, time passes and frameworks update and get more and more powerful, but it’s easy to adapt the scripts to new – if any – methods and properties.
Second, because even if – but it’s not – but even if the script is a bit obsolete, most algorithm are evergreen. An array in early 1980 remains an array in 2020 and if the algorithm says you have to iterate through an array, you have to do it, no matter if we are in 1980 or in 2020, just do it in the most comfortable or modern way you prefer.
And this game features a lot of interesting snippets, such as the one to increase difficulty as players keep matching, or the one to suggest the first move. Or the backtracking! What about move backtracking? Saving high scores? Ok, I am sure you got the point.
Less than 500 lines for a complete game:
var game;
var savedData;
var score;
var gameOptions = {
bgColors: [0x42a7bd, 0xd45477],
gameWidth: 750,
gameHeight: 1334,
tileSize: 140,
fieldSize: {
rows: 5,
cols: 5
},
fallSpeed: 250,
localStorageName: "drawsumgame"
}
window.onload = function() {
var windowRatio = window.innerWidth / window.innerHeight;
if(windowRatio < gameOptions.gameWidth / gameOptions.gameHeight){
gameOptions.gameHeight = gameOptions.gameWidth / windowRatio;
}
game = new Phaser.Game(gameOptions.gameWidth, gameOptions.gameHeight);
game.state.add("Boot", boot);
game.state.add("Preload", preload);
game.state.add("TitleScreen", titleScreen);
game.state.add("TheGame", theGame);
game.state.add("GameOver", gameOver);
game.state.start("Boot");
}
var boot = function(game){};
boot.prototype = {
preload: function(){
game.scale.pageAlignHorizontally = true;
game.scale.pageAlignVertically = true;
game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;
game.stage.disableVisibilityChange = true;
game.stage.backgroundColor = 0x26292c;
this.game.load.image("loading","assets/sprites/loading.png");
},
create: function(){
game.plugin = game.plugins.add(Phaser.Plugin.FadePlugin);
game.state.start("Preload");
}
}
var preload = function(game){};
preload.prototype = {
preload: function(){
var loadingBar = this.add.sprite(game.width / 2, game.height / 2, "loading");
loadingBar.anchor.setTo(0.5);
game.load.image("playbutton", "assets/sprites/playbutton.png");
game.load.image("hand", "assets/sprites/hand.png");
game.load.image("bigtile", "assets/sprites/bigtile.png");
game.load.image("title", "assets/sprites/title.png");
game.load.image("item", "assets/sprites/item.png");
game.load.spritesheet("tiles", "assets/sprites/tiles.png", gameOptions.tileSize, gameOptions.tileSize);
game.load.spritesheet("arrows", "assets/sprites/arrows.png", gameOptions.tileSize * 3, gameOptions.tileSize * 3);
game.load.spritesheet("numbers", "assets/sprites/numbers.png", gameOptions.tileSize, gameOptions.tileSize);
game.load.bitmapFont("bignumbersfont", "assets/fonts/bignumbersfont.png", "assets/fonts/bignumbersfont.fnt");
game.load.bitmapFont("recapfont", "assets/fonts/recapfont.png", "assets/fonts/recapfont.fnt");
game.load.bitmapFont("font", "assets/fonts/font.png", "assets/fonts/font.fnt");
game.load.audio("pop", ["assets/sounds/pop.mp3", "assets/sounds/pop.ogg"]);
game.load.audio("pop2", ["assets/sounds/pop2.mp3", "assets/sounds/pop2.ogg"]);
game.load.audio("pop3", ["assets/sounds/pop3.mp3", "assets/sounds/pop3.ogg"]);
game.load.audio("fail", ["assets/sounds/fail.mp3", "assets/sounds/fail.ogg"]);
game.load.audio("done", ["assets/sounds/done.mp3", "assets/sounds/done.ogg"]);
game.load.audio("gameover", ["assets/sounds/gameover.mp3", "assets/sounds/gameover.ogg"]);
},
create: function(){
game.plugin.fadeAndPlay("rgb(38, 41, 44)", 0.25, "TitleScreen");
}
}
var titleScreen = function(game){};
titleScreen.prototype = {
create: function(){
savedData = localStorage.getItem(gameOptions.localStorageName)==null?{score:0}:JSON.parse(localStorage.getItem(gameOptions.localStorageName));
var title = game.add.image(game.width / 2, 20, "title");
title.anchor.set(0.5, 0);
var playButton = game.add.button(game.width / 2, game.height / 2, "playbutton", this.startGame);
playButton.anchor.set(0.5);
var tween = game.add.tween(playButton).to({
width: 220,
height:220
}, 1500, "Linear", true, 0, -1, true);
game.add.bitmapText(game.width / 2, game.height - 200, "font", "BEST SCORE", 48).anchor.set(0.5);
game.add.bitmapText(game.width / 2, game.height - 100, "bignumbersfont", savedData.score.toString(), 90).anchor.set(0.5);
},
startGame: function(){
game.plugin.fadeAndPlay("rgb(38, 41, 44)", 0.25, "TheGame");
}
}
var theGame = function(){};
theGame.prototype = {
create: function(){
this.createLevel();
game.input.onDown.add(this.pickTile, this);
this.popSound = [game.add.audio("pop"), game.add.audio("pop2"), game.add.audio("pop3")];
this.failSound = game.add.audio("fail");
this.doneSound = game.add.audio("done");
this.gameOverSound = game.add.audio("gameover");
},
createLevel: function(){
game.stage.visible = false;
this.tilesArray = [];
this.arrowsArray = [];
this.targetsArray = [];
this.matches = 0;
this.energyLoss = 0.3;
this.gameOver = false;
score = 0;
this.firstPick = true;
this.tintColor = gameOptions.bgColors[0];
this.altTintColor = gameOptions.bgColors[1];
this.tileGroup = game.add.group();
this.tileGroup.x = (game.width - gameOptions.tileSize * gameOptions.fieldSize.cols) / 2;
this.tileGroup.y = (game.height - gameOptions.tileSize * gameOptions.fieldSize.rows) - 50;
this.arrowsGroup = game.add.group();
this.arrowsGroup.x = this.tileGroup.x;
this.arrowsGroup.y = this.tileGroup.y;
var item = game.add.image(game.width / 2, this.tileGroup.y - 70 , "item");
item.anchor.set(0.5);
item.tint = this.tintColor;
this.recapText = game.add.bitmapText(40, item.y - 25, "recapfont", "", 72);
this.scoreText = game.add.bitmapText(game.width - 40, item.y - 100, "font", "", 48);
this.scoreText.anchor.set(1, 0);
this.targetGroup = game.add.group();
this.arcGraphics = game.add.graphics(0, 0);
tileMask = game.add.graphics(this.tileGroup.x, this.tileGroup.y - 40);
tileMask.beginFill(0xffffff);
tileMask.drawRect(0, 0, gameOptions.tileSize * gameOptions.fieldSize.cols, gameOptions.tileSize * gameOptions.fieldSize.rows + 40);
this.tileGroup.mask = tileMask;
for(var i = 0; i < gameOptions.fieldSize.rows; i++){
this.tilesArray[i] = [];
for(var j = 0; j < gameOptions.fieldSize.cols; j++){
this.addTile(i, j);
}
}
this.removedTiles = [];
for(i = 0; i < 5; i++){
var target = game.add.sprite((i % 3) * 230 + 115 * Math.floor(i / 3), 15 + Math.floor(i / 3) * 200, "bigtile");
target.tint = this.altTintColor;
target.numberToMatch = game.rnd.between(10, 14);
target.energy = 360;
target.energyLoss = this.energyLoss
var bigNumber = game.add.bitmapText(100, 110, "bignumbersfont", target.numberToMatch.toString(), 90);
bigNumber.anchor.set(0.5);
target.addChild(bigNumber)
this.targetGroup.add(target);
this.targetsArray.push(target)
this.arcGraphics.arc(this.targetsArray[i].x + 100 + this.targetGroup.x, this.targetsArray[i].y + 100, 80, 0, Phaser.Math.degToRad(this.targetsArray[i].energy), false);
}
this.targetGroup.x = (game.width - this.targetGroup.width) / 2
this.timeLoop = game.time.events.loop(Phaser.Timer.SECOND / 20, this.updateCounter, this);
var tweenArray = this.findSum(this.targetsArray[0].numberToMatch);
if(tweenArray.length == 0){
game.state.start("TheGame");
return;
}
game.stage.visible = true;
this.finger = game.add.sprite(this.tilesArray[tweenArray[0]][tweenArray[1]].x - 80, this.tilesArray[tweenArray[0]][tweenArray[1]].y, "hand");
this.tileGroup.add(this.finger);
game.add.tween(this.finger).to({
x: this.tilesArray[tweenArray[2]][tweenArray[3]].x - 80,
y: this.tilesArray[tweenArray[2]][tweenArray[3]].y
}, 500, Phaser.Easing.Linear.None, true, 0, -1, true);
this.infoText = game.add.bitmapText(game.width / 2, item.y, "font", "Connect blue numbers to sum them and\nmatch red numbers before time runs out\n\nLonger connections give more points", 24)
this.infoText.anchor.set(0.5);
},
updateCounter: function(){
this.arcGraphics.clear();
this.arcGraphics.lineStyle(20, 0xffffff);
for(var i = 0; i < this.targetsArray.length; i++){
this.targetsArray[i].energy -= this.targetsArray[i].energyLoss;
if(this.targetsArray[i].energy > 0){
this.arcGraphics.arc(this.targetsArray[i].x + 100 + this.targetGroup.x, this.targetsArray[i].y + 100, 80, 0, Phaser.Math.degToRad(this.targetsArray[i].energy), false);
}
else{
game.time.events.remove(this.timeLoop);
this.gameOver = true;
game.add.tween(this.targetsArray[i]).to({
y: game.height + 200
}, 500, Phaser.Easing.Cubic.In, true);
}
}
if(this.gameOver){
this.gameOverSound.play();
game.time.events.loop(Phaser.Timer.SECOND * 2, function(){
game.plugin.fadeAndPlay("rgb(38, 41, 44)", 0.25, "GameOver");
}, this);
}
},
addTile: function(row, col){
var tileXPos = col * gameOptions.tileSize + gameOptions.tileSize / 2;
var tileYPos = row * gameOptions.tileSize + gameOptions.tileSize / 2;
var theTile = game.add.sprite(tileXPos, tileYPos, "tiles");
theTile.anchor.set(0.5);
theTile.picked = false;
theTile.coordinate = new Phaser.Point(col, row);
this.tilesArray[row][col] = theTile;
theTile.value = game.rnd.between(1, 9);
theTile.tint = this.tintColor;
var number = game.add.sprite(0, 0, "numbers");
number.anchor.set(0.5);
number.frame = theTile.value - 1;
theTile.addChild(number);
this.tileGroup.add(theTile);
},
pickTile: function(e){
if(this.firstPick){
this.infoText.destroy();
this.finger.destroy();
this.firstPick = false;
}
this.visitedTiles = [];
this.visitedTiles.length = 0;
if(this.tileGroup.getBounds().contains(e.position.x, e.position.y)){
var col = Math.floor((e.position.x - this.tileGroup.x) / gameOptions.tileSize);
var row = Math.floor((e.position.y - this.tileGroup.y) / gameOptions.tileSize);
this.tilesArray[row][col].tint = this.altTintColor;
this.tilesArray[row][col].picked = true;
game.input.onDown.remove(this.pickTile, this);
game.input.onUp.add(this.releaseTile, this);
game.input.addMoveCallback(this.moveTile, this);
this.visitedTiles.push(this.tilesArray[row][col].coordinate);
this.recapText.text = this.tilesArray[row][col].value;
Phaser.ArrayUtils.getRandomItem(this.popSound).play();
}
},
moveTile: function(e){
if(this.tileGroup.getBounds().contains(e.position.x, e.position.y)){
var col = Math.floor((e.position.x - this.tileGroup.x) / gameOptions.tileSize);
var row = Math.floor((e.position.y - this.tileGroup.y) / gameOptions.tileSize);
var distance = new Phaser.Point(e.position.x - this.tileGroup.x, e.position.y - this.tileGroup.y).distance(this.tilesArray[row][col]);
if(distance < gameOptions.tileSize * 0.4){
if(!this.tilesArray[row][col].picked && this.checkAdjacent(new Phaser.Point(col, row), this.visitedTiles[this.visitedTiles.length - 1])){
if(this.visitedTiles.length < 8){
this.tilesArray[row][col].picked = true;
this.tilesArray[row][col].tint = this.altTintColor;
this.visitedTiles.push(this.tilesArray[row][col].coordinate);
this.addArrow();
Phaser.ArrayUtils.getRandomItem(this.popSound).play();
}
}
else{
if(this.visitedTiles.length > 1 && row == this.visitedTiles[this.visitedTiles.length - 2].y && col == this.visitedTiles[this.visitedTiles.length - 2].x){
this.tilesArray[this.visitedTiles[this.visitedTiles.length - 1].y][this.visitedTiles[this.visitedTiles.length - 1].x].picked = false;
this.tilesArray[this.visitedTiles[this.visitedTiles.length - 1].y][this.visitedTiles[this.visitedTiles.length - 1].x].tint = this.tintColor;
this.visitedTiles.pop();
this.arrowsArray[this.arrowsArray.length - 1].destroy();
this.arrowsArray.pop();
Phaser.ArrayUtils.getRandomItem(this.popSound).play();
}
}
var stringToShow = this.tilesArray[this.visitedTiles[0].y][this.visitedTiles[0].x].value;
for(var i = 1; i < this.visitedTiles.length; i++){
stringToShow += "+" + this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x].value
}
this.recapText.text = stringToShow
}
}
},
releaseTile: function(){
this.recapText.text = "";
var didMatch = false;
var totalSum = 0;
for(var i = 0; i < this.visitedTiles.length; i++){
totalSum += this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x].value;
}
if(!this.gameOver){
for(i = 0; i < 5; i++){
if(totalSum == this.targetsArray[i].numberToMatch){
this.matches++;
var tween = game.add.tween(this.targetsArray[i]).to({
alpha: 0
}, gameOptions.fallSpeed / 2, Phaser.Easing.Linear.None, true);
this.targetsArray[i].numberToMatch = game.rnd.between(10, 14 + this.matches);
tween.onComplete.add(function(e){
e.children[0].text = e.numberToMatch;
var tween = game.add.tween(e).to({
alpha: 1
}, gameOptions.fallSpeed / 2, Phaser.Easing.Linear.None, true);
}, this);
this.targetsArray[i].energy = 360;
this.targetsArray[i].energyLoss = this.energyLoss;
didMatch = true;
score += totalSum * (this.visitedTiles.length - 1);
this.scoreText.text = score.toString();
}
}
}
game.input.onUp.remove(this.releaseTile, this);
game.input.deleteMoveCallback(this.moveTile, this);
this.arrowsGroup.removeAll(true);
if(didMatch){
this.doneSound.play();
this.energyLoss += 0.02;
this.clearPath();
this.tilesFallDown();
this.placeNewTiles();
}
else{
for(var i = 0; i < this.visitedTiles.length; i++){
this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x].tint = this.tintColor;
this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x].picked = false;
}
this.failSound.play()
this.nextPick();
}
},
checkAdjacent: function(p1, p2){
return (Math.abs(p1.x - p2.x) <= 1) && (Math.abs(p1.y - p2.y) <= 1);
},
addArrow: function(){
var fromTile = this.visitedTiles[this.visitedTiles.length - 2];
var arrow = game.add.sprite(this.tilesArray[fromTile.y][fromTile.x].x, this.tilesArray[fromTile.y][fromTile.x].y, "arrows");
arrow.tint = this.tintColor;
this.arrowsGroup.add(arrow);
arrow.anchor.set(0.5);
var tileDiff = new Phaser.Point(this.visitedTiles[this.visitedTiles.length - 1].x, this.visitedTiles[this.visitedTiles.length - 1].y)
tileDiff.subtract(this.visitedTiles[this.visitedTiles.length - 2].x, this.visitedTiles[this.visitedTiles.length - 2].y);
if(tileDiff.x == 0){
arrow.angle = -90 * tileDiff.y;
}
else{
arrow.angle = 90 * (tileDiff.x + 1);
if(tileDiff.y != 0){
arrow.frame = 1;
if(tileDiff.y + tileDiff.x == 0){
arrow.angle -= 90;
}
}
}
this.arrowsArray.push(arrow);
},
clearPath: function(){
for(var i = 0; i < this.visitedTiles.length; i++){
this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x].visible = false;
this.removedTiles.push(this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x]);
this.tilesArray[this.visitedTiles[i].y][this.visitedTiles[i].x] = null;
}
},
tilesFallDown: function(){
for(var i = gameOptions.fieldSize.cols - 1; i >= 0; i--){
for(var j = 0; j < gameOptions.fieldSize.rows; j++){
if(this.tilesArray[i][j] != null){
var holes = this.holesBelow(i, j);
if(holes > 0){
var coordinate = new Phaser.Point(this.tilesArray[i][j].coordinate.x, this.tilesArray[i][j].coordinate.y);
var destination = new Phaser.Point(j, i + holes);
var tween = game.add.tween(this.tilesArray[i][j]).to({
y: this.tilesArray[i][j].y + holes * gameOptions.tileSize
}, gameOptions.fallSpeed, Phaser.Easing.Linear.None, true);
tween.onComplete.add(this.nextPick, this)
this.tilesArray[destination.y][destination.x] = this.tilesArray[i][j]
this.tilesArray[coordinate.y][coordinate.x] = null;
this.tilesArray[destination.y][destination.x].coordinate = new Phaser.Point(destination.x, destination.y)
this.tilesArray[destination.y][destination.x].children[0].text = "R" + destination.y + ", C" + destination.x;
}
}
}
}
},
placeNewTiles: function(){
for(var i = 0; i < gameOptions.fieldSize.cols; i++){
var holes = this.holesInCol(i);
if(holes > 0){
for(var j = 1; j <= holes; j++){
var tileXPos = i * gameOptions.tileSize + gameOptions.tileSize / 2;
var tileYPos = -j * gameOptions.tileSize + gameOptions.tileSize / 2;
var theTile = this.removedTiles.pop();
theTile.position = new Phaser.Point(tileXPos, tileYPos);
theTile.visible = true;
theTile.tint = this.tintColor;
theTile.picked = false;
var tween = game.add.tween(theTile).to({
y: theTile.y + holes * gameOptions.tileSize
}, gameOptions.fallSpeed, Phaser.Easing.Linear.None, true)
tween.onComplete.add(this.nextPick, this)
theTile.coordinate = new Phaser.Point(i, holes - j);
theTile.value = game.rnd.between(1, 9);
theTile.children[0].frame = theTile.value - 1;
this.tilesArray[holes - j][i] = theTile;
}
}
}
},
nextPick: function(){
if(!game.input.onDown.has(this.pickTile, this)){
game.input.onDown.add(this.pickTile, this);
}
},
holesBelow: function(row, col){
var result = 0;
for(var i = row + 1; i < gameOptions.fieldSize.rows; i++){
if(this.tilesArray[i][col] == null){
result ++;
}
}
return result;
},
holesInCol: function(col){
var result = 0;
for(var i = 0; i < gameOptions.fieldSize.rows; i++){
if(this.tilesArray[i][col] == null){
result ++;
}
}
return result;
},
findSum: function(n){
for(var i = 1; i < gameOptions.fieldSize.rows - 3; i++){
for(var j = 1; j < gameOptions.fieldSize.cols - 2; j++){
for(var k = 0; k <= 1; k++){
for(var l = 0; l <= 1; l++){
var newRow = i + k;
var newCol = j + l;
if((k != 0 || l != 0) && newRow < gameOptions.fieldSize.rows && newRow >= 0 && newCol < gameOptions.fieldSize.cols && newCol >= 0){
if(this.tilesArray[i][j].value + this.tilesArray[newRow][newCol].value == n){
return([i, j, newRow, newCol]);
}
}
}
}
}
}
return [];
}
}
var gameOver = function(){};
gameOver.prototype = {
create: function(){
var bestScore = Math.max(score, savedData.score);
game.add.bitmapText(game.width / 2, 100, "font", "Your score", 48).anchor.set(0.5);
game.add.bitmapText(game.width / 2, 200, "bignumbersfont", score.toString(), 90).anchor.set(0.5);
game.add.bitmapText(game.width / 2, game.height - 200, "font", "Best score", 48).anchor.set(0.5);
game.add.bitmapText(game.width / 2, game.height - 100, "bignumbersfont", bestScore.toString(), 90).anchor.set(0.5);
localStorage.setItem(gameOptions.localStorageName,JSON.stringify({
score: bestScore
}));
var playButton = game.add.button(game.width / 2, game.height /2, "playbutton", this.startGame);
playButton.anchor.set(0.5);
var tween = game.add.tween(playButton).to({
width: 220,
height:220
}, 1500, "Linear", true, 0, -1, true);
},
startGame: function(){
game.plugin.fadeAndPlay("rgb(38, 41, 44)", 0.25, "TheGame");
}
}
Phaser.Plugin.FadePlugin = function (game, parent) {
Phaser.Plugin.call(this, game, parent);
};
Phaser.Plugin.FadePlugin.prototype = Object.create(Phaser.Plugin.prototype);
Phaser.Plugin.FadePlugin.prototype.constructor = Phaser.Plugin.SamplePlugin;
Phaser.Plugin.FadePlugin.prototype.fadeAndPlay = function (style, time, nextState) {
this.crossFadeBitmap = this.game.make.bitmapData(this.game.width, this.game.height);
this.crossFadeBitmap.rect(0, 0, this.game.width, this.game.height, style);
this.overlay = this.game.add.sprite(0, 0, this.crossFadeBitmap);
this.overlay.alpha = 0;
var fadeTween = this.game.add.tween(this.overlay);
fadeTween.to({
alpha:1
},
time * 1000, Phaser.Easing.None, true);
fadeTween.onComplete.add(function(){
this.game.state.start(nextState);
}, this);
};
I will release a Phaser 3 commented version really soon – it’s part of another project – meanwhile download the source code of this game, have fun and if you create something interesting out of it, just let me know.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.