Get the full commented source code of

HTML5 Suika Watermelon Game

Talking about Game development, HTML5, Javascript and Phaser.

Do you remember my DrawSum HTML5 math game?

It was a commercial game built with an older Phaser CE version, whose source code I released for free, and although the Phaser version was a bit outdated, it’s easy to port it to Phaser 3.

Today I am showing you a pure JavaScript class to handle math draw games like DrawSum.

It does not have any dependency so you can use it with your favourite framework, and you just have to call five methods to handle the entire game engine. This way, you will only need to focus on input management and visual effects.

First things first, let’s have a look at a prototype built in a bunch of lines thanks to this class:

Draw to connect tiles and watch the sum update in real time. Then, look at new tiles falling with new numbers.

This is all managed by a bunch of methods:

let drawSum = new DrawSum({
    rows: 5,
    columns: 5,
    items: 9
});

This constructor builds a new game field with five rows, five columns and nine items.

How to see what we built, and add sprites accordingly?

let board = JSON.parse(drawSum.getBoardStatus());

getBoardStatus returns a JSON string like this one:

[
{"row":0,"column":0,"value":9,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":1,"value":5,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":2,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":3,"value":5,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":4,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":0,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":1,"value":9,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":2,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":3,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":4,"value":4,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":0,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":1,"value":9,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":2,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":3,"value":3,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":4,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":0,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":1,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":2,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":3,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":4,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":0,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":1,"value":8,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":2,"value":1,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":3,"value":5,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":4,"value":7,"inChain":false,"prevChain":false,"nextChain":false}
]

Let’s have a look at the items:

row: item row number.

column: item column number.

value: item value

inChain: false if not selected, or a number representing the order in the chain if selected. First item has 0 as inChain value.

prevChain: false if the item does not have a previous item in the chain, or the coordinates of the previous item if it exists.

nextChain: false if the item does not have a previous item in the chain, or the coordinates of the previous item if it exists.

With these data, you are able to draw your game field, connect selected items, and do everything to display the game.

How do you link graphic assets to your game board?

drawSum.setCustomData(row, column, data);

setCustomData method saves anything you want in the item at (row, column) coordinate.

Following the same concept, we can retrieve data when needed:

let data = drawSum.getCustomData(row, column);

getCustomData method retrieves the data stored with setCustomData.

How do you pick items?

drawSum.pickItemAt(row, column);

pickItemAt method handles everything involved in item selection. It checks if the item is in a legal position, if it can be added to the chain of selected items or if we have to discard the latest item in the chain because the player is backtracking.

One method to rule them all, and it just returns true if this was a legal move or false, if it wasn’t.

Then, you can update your graphics calling getBoardStatus one more time.

When you completed the selection, there is one last method to call:

drawSum.handleMove();

handleMove method returns all movement information, as a JSON string to handle items to be removed, items to make fall down and items to make fall from above to replenish the board, like in this example:

{
"itemsToArrange":[
{"row":2,"column":1,"deltaRow":1},
{"row":2,"column":2,"deltaRow":1},
{"row":3,"column":3,"deltaRow":2},
{"row":1,"column":1,"deltaRow":1},
{"row":1,"column":2,"deltaRow":1},
{"row":2,"column":3,"deltaRow":2}
],
"itemsToReplenish":[
{"row":0,"column":1,"deltaRow":1},
{"row":0,"column":2,"deltaRow":1},
{"row":0,"column":3,"deltaRow":2},
{"row":1,"column":3,"deltaRow":2}
]}

itemsToArrange and itemsToReplenish items contain the row and the column of the item to move, and the amount of rows (deltaRow) to move to.

Look at the complete example:

let game;

let gameOptions = {
    cellSize: 140,
    fallSpeed: 100,
    destroySpeed: 200,
    boardOffset: {
        x: 25,
        y: 25
    }
}

window.onload = function() {
    let gameConfig = {
        type: Phaser.AUTO,
        scale: {
            mode: Phaser.Scale.FIT,
            autoCenter: Phaser.Scale.CENTER_BOTH,
            parent: "thegame",
            width: 750,
            height: 1334
        },
        scene: playGame
    }
    game = new Phaser.Game(gameConfig);
    window.focus();
}

class playGame extends Phaser.Scene {

    constructor() {
        super("PlayGame");
    }

    preload() {
        this.load.spritesheet("gems", "assets/sprites/gems.png", {
            frameWidth: gameOptions.cellSize,
            frameHeight: gameOptions.cellSize
        });
        this.load.spritesheet("arrows", "assets/sprites/arrows.png", {
            frameWidth: gameOptions.cellSize * 3,
            frameHeight: gameOptions.cellSize * 3
        });
    }
    create() {
        this.canPick = true;
        this.drawing = false;

        // constructor: here we define the number of rows, columns and different items
        this.drawSum = new DrawSum({
            rows: 5,
            columns: 5,
            items: 9
        });

        // getBoardStatus() returns a JSON string with all board information
        let board = JSON.parse(this.drawSum.getBoardStatus());
        board.forEach(cell => this.addSprite(cell))
        this.sumText = this.add.text(game.config.width / 2, 900, "0", {
            fontFamily: "Arial",
            fontSize: 256,
            color: "#2394bc"
        });
        this.sumText.setOrigin(0.5, 0);
        this.input.on("pointerdown", this.startDrawing, this);
        this.input.on("pointermove", this.keepDrawing, this);
        this.input.on("pointerup", this.stopDrawing, this);
    }

    // adding sprites
    addSprite(cell) {
        let posX = gameOptions.boardOffset.x + gameOptions.cellSize * cell.column + gameOptions.cellSize / 2;
        let posY = gameOptions.boardOffset.y + gameOptions.cellSize * cell.row + gameOptions.cellSize / 2
        let item = this.add.sprite(posX, posY, "gems", cell.value - 1);
        let arrow = this.add.sprite(posX, posY, "arrows");
        arrow.setDepth(2);
        arrow.visible = false;

        // setCustomData method allows us to inject custom information in the board,
        // in our case an object containing both item and arrow sprites
        this.drawSum.setCustomData(cell.row, cell.column, {
            itemSprite: item,
            arrowSprite: arrow
        });
    }

    // method to call when the player presses the input
    startDrawing(pointer) {
        if (this.canPick) {
            this.handleDrawing(pointer, true)
        }
    }

    // method to call when the player moves the input
    keepDrawing(pointer) {
        if (this.drawing && this.pointerInside(pointer)) {
            this.handleDrawing(pointer, false);
        }
    }

    // method to handle player movement
    handleDrawing(pointer, firstPick) {
        let row = Math.floor((pointer.y - gameOptions.boardOffset.y) / gameOptions.cellSize);
        let col = Math.floor((pointer.x - gameOptions.boardOffset.x) / gameOptions.cellSize);

        // pickItemAt method handles player pick
        let pickedItem = this.drawSum.pickItemAt(row, col);
        if (pickedItem) {
            this.displayValue();
            this.updateVisuals(false);
            if (firstPick) {
                this.canPick = false;
                this.drawing = true;
            }
        }
    }

    // just a method to update visuals
    updateVisuals(changeFrame) {
        let board = JSON.parse(this.drawSum.getBoardStatus());
        board.forEach(cell => this.handleCell(cell, changeFrame));
    }

    // method to draw and update sprites
    handleCell(cell, changeFrame) {

        // getCustomData method retrieves the information previously stored with setCustomData
        this.drawSum.getCustomData(cell.row, cell.column).itemSprite.alpha = cell.inChain !== false ? 0.5 : 1;
        if (changeFrame) {
            this.drawSum.getCustomData(cell.row, cell.column).itemSprite.setFrame(cell.value - 1);
        }
        let arrow = this.drawSum.getCustomData(cell.row, cell.column).arrowSprite;
        if (cell.nextChain) {
            arrow.visible = true;
            let deltaRow = cell.nextChain.row - cell.row;
            let deltaColumn = cell.nextChain.column - cell.column;
            arrow.setFrame(deltaRow == 0 ? 0 : (deltaColumn == 0 ? 0 : 1));
            arrow.angle = deltaRow == -1 ? (deltaColumn == 0 ? -90 : (deltaColumn == 1 ? 0 : -90)) : (deltaRow == 0 ? (deltaColumn == -1 ? 180 : 0) : (deltaColumn == 0 ? 90 : (deltaColumn == 1 ? 90 : 180)))
        }
        else {
            arrow.visible = false;
        }
    }

    // method to call when the input stops
    stopDrawing() {
        if (this.drawing) {
            this.drawing = false;
            // handleMove method processes player move and returns a JSON string with all movement information
            this.moves = JSON.parse(this.drawSum.handleMove());
            this.displayValue();
            this.updateVisuals();
            this.makeItemsDisappear();
        }
    }

    // method to remove items
    makeItemsDisappear() {
        this.destroyed = 0;
        this.moves.itemsToReplenish.forEach (item => this.fadeItem(item));
    }

    // method to fade out sprites
    fadeItem(item) {
        this.destroyed ++;
        let data = this.drawSum.getCustomData(item.row, item.column);
        this.tweens.add({
            targets: data.itemSprite,
            alpha: 0,
            duration: gameOptions.destroySpeed,
            callbackScope: this,
            onComplete: function(event, sprite) {
                this.destroyed --;
                if (this.destroyed == 0) {
                    this.makeItemsFall();
                }
            }
        });
    }

    // method to handle items falling
    makeItemsFall() {
        this.updateVisuals(true);
        this.moved = 0;
        this.moves.itemsToArrange.forEach(movement => this.fallDown(movement));
        this.moves.itemsToReplenish.forEach(movement => this.fallFromTop(movement));
    }

    // method to make items fall down
    fallDown(movement) {
        let data = this.drawSum.getCustomData(movement.row, movement.column);
        this.tweenItem([data.itemSprite, data.arrowSprite], data.itemSprite.y + movement.deltaRow * gameOptions.cellSize, gameOptions.fallSpeed * Math.abs(movement.deltaRow))
    }

    // method to make items fall from top
    fallFromTop(movement) {
        let data = this.drawSum.getCustomData(movement.row, movement.column);
        data.itemSprite.alpha = 1;
        data.itemSprite.y = gameOptions.boardOffset.y + gameOptions.cellSize * (movement.row - movement.deltaRow + 1) - gameOptions.cellSize / 2;
        data.itemSprite.x = gameOptions.boardOffset.x + gameOptions.cellSize * movement.column + gameOptions.cellSize / 2,
        this.tweenItem([data.itemSprite, data.arrowSprite], gameOptions.boardOffset.y + gameOptions.cellSize * movement.row + gameOptions.cellSize / 2, gameOptions.fallSpeed * movement.deltaRow);
    }

    // method to tween an item
    tweenItem(item, destinationY, duration) {
        this.moved ++;
        this.tweens.add({
            targets: item,
            y: destinationY,
            duration: duration,
            callbackScope: this,
            onComplete: function() {
                this.moved --;
                if(this.moved == 0){
                    this.canPick = true;
                }
            }
        });
    }

    // method to display the sum of the selected items
    displayValue() {
        let sum = 0;
        let board = JSON.parse(this.drawSum.getBoardStatus());
        board.forEach (function(cell) {
            if (cell.inChain !== false) {
                sum += cell.value;
            }
        });
        this.sumText.setText(sum);
    }

    // method to check if the pointer is inside enough a cell, to allow player to easily move diagonally
    pointerInside(pointer) {
        let row = Math.floor((pointer.y - gameOptions.boardOffset.y) / gameOptions.cellSize);
        let column = Math.floor((pointer.x - gameOptions.boardOffset.x) / gameOptions.cellSize);
        let perfectX = column * gameOptions.cellSize + gameOptions.boardOffset.x + gameOptions.cellSize / 2;
        let perfectY = row * gameOptions.cellSize + gameOptions.boardOffset.y + gameOptions.cellSize / 2;
        let manhattanDistance = Math.abs(pointer.x - perfectX) + Math.abs(pointer.y - perfectY);
        return manhattanDistance < gameOptions.cellSize * 0.4;
    }
}

// DrawSum class
class DrawSum {

    // constructor, sets up and builds the game field
    constructor(obj) {
        if (obj == undefined) {
            obj = {};
        }
        this.rows = (obj.rows != undefined) ? obj.rows : 8;
        this.columns = (obj.columns != undefined) ? obj.columns : 7;
        this.items = (obj.items != undefined) ? obj.items : 6;
        this.chain = new Chain();
        this.gameArray = [];
        for (let i = 0; i < this.rows; i ++) {
            this.gameArray[i] = [];
            for (let j = 0; j < this.columns; j ++) {
                let randomValue = Math.ceil(Math.random() * this.items);
                this.gameArray[i][j] = new Cell(new Item(randomValue))
            }
        }
    }

    // returns a JSON string with board status
    getBoardStatus() {
        let board = [];
        for (let i = 0; i < this.rows; i ++) {
            for (let j = 0; j < this.columns; j ++) {
                board.push({
                    row: i,
                    column: j,
                    value: this.gameArray[i][j].getItemValue(),
                    inChain: this.chain.getPosition(i, j),
                    prevChain: this.chain.getPrevious(i, j),
                    nextChain: this.chain.getNext(i, j)
                });
            }
        }
        return JSON.stringify(board);
    }

    // tries to pick an item at (row, column). Returns true if the pick was successful, false otherwise
    pickItemAt(row, column) {
        if (this.isValidPick(row, column)) {
            if (this.chain.canContinue(row, column)) {
                this.chain.add(row, column, this.gameArray[row][column].getItem());
                return true;
            }
            if (this.chain.isBacktrack(row, column)) {
                this.chain.backtrack();
                return true;
            }
            return false;
        }
        return false;
    }

    // returns true if the item at (row, column) is a valid pick, false otherwise
    isValidPick(row, column) {
        return row >= 0 && row < this.rows && column >= 0 && column < this.columns;
    }

    // sets a custom data of the item at (row, column)
    setCustomData(row, column, customData) {
        this.gameArray[row][column].setItemData(customData);
    }

    // returns the custom data of the item at (row, column)
    getCustomData(row, column) {
        return this.gameArray[row][column].getItemData();
    }

    // handles the board after a move, and return a JSON object with movement information
    handleMove() {
        let result = this.chain.export();
        this.chain.clear();
        result.forEach(function(item){
            this.gameArray[item.row][item.column].setEmpty(true);
        }.bind(this));
        return JSON.stringify({
            itemsToArrange: this.arrangeBoard(),
            itemsToReplenish: this.replenishBoard()
        });
    }

    // swaps the items at (row, column) and (row2, column2)
    swapItems(row, column, row2, column2) {
        let tempObject = this.gameArray[row][column];
        this.gameArray[row][column] = this.gameArray[row2][column2];
        this.gameArray[row2][column2] = tempObject;
    }

    // returns the amount of empty spaces below the item at (row, column)
    emptySpacesBelow(row, column) {
        let result = 0;
        if (row != this.rows) {
            for (let i = row + 1; i < this.rows; i ++) {
                if (this.gameArray[i][column].isEmpty()) {
                    result ++;
                }
            }
        }
        return result;
    }

    // arranges the board after a chain, making items fall down. Returns an object with movement information
    arrangeBoard() {
        let result = []
        for (let i = this.rows - 2; i >= 0; i --) {
            for (let j = 0; j < this.columns; j ++) {
                let emptySpaces = this.emptySpacesBelow(i, j);
                if(!this.gameArray[i][j].isEmpty() && emptySpaces > 0) {
                    this.swapItems(i, j, i + emptySpaces, j);
                    result.push({
                        row: i + emptySpaces,
                        column: j,
                        deltaRow: emptySpaces
                    });
                }
            }
        }
        return result;
    }

    // replenishes the board and returns an object with movement information
    replenishBoard() {
        let result = [];
        for (let i = 0; i < this.columns; i ++) {
            if (this.gameArray[0][i].isEmpty()) {
                let emptySpaces = this.emptySpacesBelow(0, i) + 1;
                for (let j = 0; j < emptySpaces; j ++) {
                    let randomValue = Math.ceil(Math.random() * this.items);
                    result.push({
                        row: j,
                        column: i,
                        deltaRow: emptySpaces
                    });
                    this.gameArray[j][i].setItemValue(randomValue);
                    this.gameArray[j][i].setEmpty(false);
                }
            }
        }
        return result;
    }
}

// Chain class
class Chain {

    // constructor, sets the chain as an empty array
    constructor() {
        this.chain = [];
    }

    // adds an item
    add(row, column, item) {
        this.chain.push({
            item: item,
            row: row,
            column: column
        });
    }

    // clears the chain
    clear() {
        this.chain = [];
        this.chain.length = 0;
    }

    // backtracks the chain and returns the removed item
    backtrack() {
        return this.chain.pop();
    }

    // returns the position of the item at (row, column) in chain, or false otherwise
    getPosition(row, column) {
        for(let i = 0; i < this.chain.length; i ++){
            if (this.chain[i].row  == row && this.chain[i].column == column) {
                return i;
            }
        }
        return false;
    }

    // returns the coordinates of previous item relative to item at (row, column)
    getPrevious(row, column) {
        let position = this.getPosition(row, column);
        if (position > 0) {
            return {
                row: this.chain[position - 1].row,
                column: this.chain[position - 1].column
            }
        }
        return false;
    }

    // returns the coordinates of the next item relative to item at (row, column)
    getNext(row, column) {
        let position = this.getPosition(row, column);
        if (position !== false && position < this.chain.length - 1) {
            return {
                row: this.chain[position + 1].row,
                column: this.chain[position + 1].column
            }
        }
        return false;
    }

    // returns true if item at (row, column) can be next chain item
    canBeNext(row, column) {
        if (this.chain.length == 0) {
            return true;
        }
        let lastItem = this.chain[this.chain.length - 1];
        return (Math.abs(row - lastItem.row) + Math.abs(column - lastItem.column) == 1) || (Math.abs(row - lastItem.row) == 1 && Math.abs(column - lastItem.column) == 1);
    }

    // return true if the element at (row, column) is the element to backtrack
    isBacktrack(row, column) {
        return this.getPosition(row, column) == this.chain.length - 2;
    }

    // returns true if the item at (row, column) continues the chain
    canContinue(row, column) {
        return this.getPosition(row, column) === false && this.canBeNext(row, column);
    }

    // exports the chain
    export() {
        let path = [];
        for (let i = 0; i < this.chain.length; i ++) {
            path.push({
                row: this.chain[i].row,
                column: this.chain[i].column,
            });
        }
        return path;
    }
}

//  Cell class
class Cell {

    // constructor, has an item and an "empty" property
    constructor(item) {
        this.item = item;
        this.empty = false;
    }

    // returns true if the cell is empty
    isEmpty() {
        return this.empty;
    }

    // sets empty status
    setEmpty(empty) {
        this.empty = empty;
    }

    // returns the item
    getItem() {
        return this.item;
    }

    // returns item value
    getItemValue() {
        return this.item.getValue();
    }

    // sets item value
    setItemValue(value) {
        this.item.setValue(value);
    }

    // returns item data
    getItemData() {
        return this.item.getData();
    }

    // sets item data
    setItemData(data) {
        return this.item.setData(data);
    }
}

// Item class
class Item {

    // constructor, has a value and a custom data
    constructor(value, data) {
        this.value = value;
        this.customData = data;
    }

    // returns custom data
    getData() {
        return this.customData;
    }

    // sets custom data
    setData(data) {
        this.customData = data;
    }

    // returns value
    getValue() {
        return this.value;
    }

    // sets value
    setValue(value) {
        this.value = value;
    }
}

Try to play with this example and you will find really easy to code your own draw and sum game. I am rebuilding the original DrawSum game with it.

Download the source code of the entire project, and build your draw and sum game.

Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.