Pure TypeScript class to handle math draw games: build your HTML5 math game in a matter of minutes – Phaser example included
Talking about Draw and Sum game, Game development, HTML5, Javascript, Phaser and TypeScript.
Some years ago I published a JavaScript class to handle math drawing games like DrawSum, but this time I rewrote the class with TypeScript adding a lot of features to let you build your math draw game in a matter of minutes.
The goal is not only to make the game work, but also to design it in a clean, strongly-typed, and maintainable way.
The main DrawSum class will manage the board logic using arrays of tiles, handle the chain of selected tiles, calculate scores and apply gravity, compute all positions and transitions needed to animate sprites in the way you prefer and also handle input coordinates.
This means you can fully separate game logic from rendering, while still keeping rendering easy to implement.
All classes, types, and methods are documented using JSDoc.
This provides inline explanations directly in the code, improves editor IntelliSense (e.g., in VS Code), and makes it easier for developers to understand the purpose of each property or function at a glance.
With the right tools (like TypeDoc), JSDoc comments can even be turned into browsable HTML documentation.
With a few lines of Phaser, I was able to build this example:
Select numbers by drawing on the board, you can also backtrack, and watch new tiles fall.
Let’s see the development, file by file:
drawsumTypes.ts – defining types and enums
Instead of anonymous objects, TypeScript lets us define clear types for everything we need.
/**
* Represents a board coordinate (row and column).
*/
export type DrawSumCoordinate = {
row: number;
column: number;
};
/**
* Represents movement direction between two coordinates in the chain.
*/
export enum DrawSumDirection {
Up = 'up',
Down = 'down',
Left = 'left',
Right = 'right',
UpLeft = 'up-left',
UpRight = 'up-right',
DownLeft = 'down-left',
DownRight = 'down-right',
None = 'none'
}
/**
* Types of moves that can occur in the DrawSum chain.
*/
export enum DrawSumMoveType {
/** Indicates a tile was added to the chain */
Added = 'added',
/** Indicates a tile was removed from the chain (backtrack) */
Removed = 'removed'
}
/**
* Represents the result of processing a move in DrawSum.
*/
export type DrawSumMoveResult = {
/** Tiles that have been removed */
itemsToRemove: DrawSumCoordinate[];
/** Tiles that have been moved to fill empty spaces */
itemsToArrange: DrawSumFallingTile[];
/** New tiles added to the board to replace empty spaces */
itemsToReplenish: DrawSumFallingTile[];
};
/**
* Configuration options for initializing a DrawSum game.
* All properties are optional.
*/
export type DrawSumOptions = {
/** Number of rows on the board */
rows?: number;
/** Number of columns on the board */
columns?: number;
/** Number of different tile values */
items?: number;
/** Size in pixels of each tile */
cellSize?: number;
/**
* Represents the minimum percentage (between 0 and 1) of a tile's size
* that must be entered to consider a move action as valid.
*
* For example, with `activeRatio = 0.3`, a tile is considered "active"
* only when at least 30% of its width and height has been entered by the pointer.
*
* This is useful to avoid accidental transitions between adjacent tiles
* when moving near the edge.
*/
activeRatio?: number;
};
/**
* Represents the status of a single tile on the board,
* including value, position, and rendering metadata.
*/
export type DrawSumTileInfo = {
/** Board coordinate of the tile */
coordinate: DrawSumCoordinate;
/** X pixel position of the top-left corner of the tile */
posX: number;
/** Y pixel position of the top-left corner of the tile */
posY: number;
/** X pixel position of the tile's center */
centerX: number;
/** Y pixel position of the tile's center */
centerY: number;
/** Numeric value of the tile */
value: number;
/** Arbitrary custom data associated with this tile (e.g., Phaser sprites) */
customData: any;
};
/**
* Represents a single change in the DrawSum chain,
* used for tracking additions and removals of tiles.
*/
export type DrawSumMove = {
/** Coordinate of the tile involved in the move */
coordinate: DrawSumCoordinate;
/** Custom data associated with the tile */
customData: any;
/** Whether the tile was added or removed from the chain */
type: DrawSumMoveType;
/** Reference to custom data of the tile with an arrow, or false if none */
arrowCustomData: any | false;
/** Direction of the arrow between the previous tile and this one */
arrowDirection: DrawSumDirection;
};
/**
* Represents a tile affected by gravity or replenishment,
* including its movement details on the board.
*/
export type DrawSumFallingTile = {
/** Coordinate of the tile after movement */
coordinate: DrawSumCoordinate;
/** Number of rows the tile moved down */
deltaRow: number;
/** Start Y position in pixels */
start: number;
/** End Y position in pixels */
end: number;
/** Start center Y position in pixels */
centerStart: number;
/** End center Y position in pixels */
centerEnd: number;
/** Custom data (e.g., reference to Phaser sprite) */
customData: any;
};
drawsumTile.ts – the tile
Each cell in the grid is represented by a tile. It holds a numeric value, an empty flag, and optional customData where you can attach whatever kind of sprite.
/**
* Represents a single tile on the game board.
* A tile holds a numeric value and optional custom data,
* and can also be marked as empty.
*/
export class DrawSumTile {
private empty: boolean = false;
/**
* Creates a new game tile.
*
* Uses parameter property shorthand to declare and initialize
* the private fields `value` and `customData`.
*
* Equivalent to:
* ```ts
* private value: number;
* private customData: any;
* constructor(value: number, customData?: any) {
* this.value = value;
* this.customData = customData;
* }
* ```
*
* @param value - The numeric value assigned to the tile
* @param customData - Optional data attached to the tile (e.g., sprites or metadata)
*/
constructor(private value: number, private customData?: any) {}
/**
* Checks whether the tile is empty.
*
* @returns True if the tile is empty, otherwise false
*/
isEmpty(): boolean {
return this.empty;
}
/**
* Marks the tile as empty or not.
*
* @param empty - Whether the tile should be considered empty
*/
setEmpty(empty: boolean): void {
this.empty = empty;
}
/**
* Gets the numeric value of the tile.
*
* @returns The tile's value
*/
getValue(): number {
return this.value;
}
/**
* Sets the numeric value of the tile.
*
* @param value - The new value to assign to the tile
*/
setValue(value: number): void {
this.value = value;
}
/**
* Gets the custom data associated with the tile.
*
* @returns The tile's custom data
*/
getCustomData(): any {
return this.customData;
}
/**
* Sets custom data for the tile.
*
* @param data - The custom data to assign
*/
setCustomData(data: any): void {
this.customData = data;
}
}
drawsumChain.ts – the chain of selections
The chain stores the sequence of tiles currently selected by the player. It supports adding, backtracking, and checking adjacency.
import { DrawSumDirection, DrawSumCoordinate } from './';
/**
* Manages the sequence of coordinates of the tiles selected during a match attempt.
* The chain stores tile positions and allows backtracking and validation.
*/
export class DrawSumChain {
/**
* Internal list of selected chain tiles.
*/
private chain: DrawSumCoordinate[] = [];
/**
* Adds a coordinate to the chain.
*
* @param coordinate - The coordinate of the tile to add
*/
add(coordinate: DrawSumCoordinate): void {
this.chain.push(coordinate);
}
/**
* Clears the entire chain.
*/
clear(): void {
this.chain.length = 0;
this.chain = [];
}
/**
* Removes and returns the last tile in the chain.
*
* @returns The removed coordinate or false if the chain is empty
*/
backtrack(): DrawSumCoordinate | false {
return (this.chain.length > 0) ? this.chain.pop() as DrawSumCoordinate : false;
}
/**
* Finds the position of a coordinate in the chain by its row and column.
*
* @param row - Row index
* @param column - Column index
* @returns The index in the chain or false if not found
*/
getPosition(row: number, column: number): number | false {
for (let i: number = 0; i < this.chain.length; i++) {
if (this.chain[i].row === row && this.chain[i].column === column) {
return i;
}
}
return false;
}
/**
* Returns the length of the chain.
*
* @returns The length of the chain
*/
getLength(): number {
return this.chain.length;
}
/**
* Returns the first coordinate in the chain.
*
* @returns The first DrawSumCoordinate, or false if the chain is empty
*/
getFirst(): DrawSumCoordinate | false {
return this.chain.length > 0 ? this.chain[0] : false;
}
/**
* Returns the last coordinate in the chain.
*
* @returns The last DrawSumCoordinate, or false if the chain is empty
*/
getLast(): DrawSumCoordinate | false {
return this.chain.length > 0 ? this.chain[this.chain.length - 1] : false;
}
/**
* Returns the nth coordinate in the chain (zero-based index).
*
* @param index - The position in the chain
* @returns The DrawSumCoordinate at the given index, or false if out of bounds
*/
getNth(index: number): DrawSumCoordinate | false {
return index >= 0 && index < this.chain.length ? this.chain[index] : false;
}
/**
* Returns the second-to-last coordinate in the chain.
* It's important because it's the last chain tile with an arrow.
*
* @returns The second-last coordinate, or false if the chain has fewer than 2 elements
*/
getSecondLast(): DrawSumCoordinate | false {
return this.chain.length >= 2 ? this.chain[this.chain.length - 2] : false;
}
/**
* Determines whether a new coordinate is adjacent and can be added next in the chain.
*
* @param row - Row index
* @param column - Column index
* @returns True if the coordinate is adjacent to the last coordinate
*/
canBeNext(row: number, column: number): boolean {
if (this.chain.length === 0) {
return true;
}
const last = this.chain[this.chain.length - 1];
return (
Math.abs(row - last.row) + Math.abs(column - last.column) === 1 ||
(Math.abs(row - last.row) === 1 && Math.abs(column - last.column) === 1)
);
}
/**
* Checks if the selected coordinate matches the second-to-last chain tile, enabling backtrack.
*
* @param coordinate - The coordinate to check
* @returns True if the move is a valid backtrack
*/
isBacktrack(coordinate: DrawSumCoordinate): boolean {
return this.getPosition(coordinate.row, coordinate.column) === this.chain.length - 2;
}
/**
* Checks if a coordinate can be added to the chain (not already in it and adjacent).
*
* @param coordinate - The coordinate to check
* @returns True if the tile can be added
*/
canContinue(coordinate: DrawSumCoordinate): boolean {
return this.getPosition(coordinate.row, coordinate.column) === false && this.canBeNext(coordinate.row, coordinate.column);
}
/**
* Returns the direction from the coordinate at the given index
* to the coordinate at index + 1.
*
* @param index - The index in the chain to compare from
* @returns A DrawSumDirection indicating relative movement, or 'none' if not applicable
*/
getDirection(index: number): DrawSumDirection {
const current = this.getNth(index);
const next = this.getNth(index + 1);
if (!current || !next) {
return DrawSumDirection.None;
}
const deltaX: number = next.column - current.column;
const deltaY: number = next.row - current.row;
const directionMap: Record<string, DrawSumDirection> = {
'0,-1': DrawSumDirection.Up,
'0,1': DrawSumDirection.Down,
'-1,0': DrawSumDirection.Left,
'1,0': DrawSumDirection.Right,
'-1,-1': DrawSumDirection.UpLeft,
'1,-1': DrawSumDirection.UpRight,
'-1,1': DrawSumDirection.DownLeft,
'1,1': DrawSumDirection.DownRight
};
return directionMap[`${deltaX},${deltaY}`] ?? DrawSumDirection.None;
}
/**
* Returns a shallow clone of all coordinates in the chain.
*
* @returns An array of DrawSumCoordinate objects
*/
export(): DrawSumCoordinate[] {
return this.chain.map(({ row, column }: DrawSumCoordinate) => ({ row, column }));
}
}
drawsum.ts – the board
The DrawSum class is the real engine: it manages the 2D array of tiles, validates and updates the chain, computes scores, processes gravity (arrangeBoard) and replenishment (replenishBoard), and calculates start/end positions in pixels for all tiles, so your framework can animate them.
import { DrawSumChain, DrawSumTile, DrawSumFallingTile, DrawSumOptions, DrawSumTileInfo, DrawSumMove, DrawSumMoveType, DrawSumMoveResult, DrawSumCoordinate } from './index';
/**
* Main class for the DrawSum match-3-like game.
* Manages the board, chain logic, tile interactions, and game flow.
*/
export class DrawSum {
/** Number of rows in the game board */
private rows: number;
/** Number of columns in the game board */
private columns: number;
/** Number of distinct tile values available */
private items: number;
/** Size of each tile, in pixels */
private cellSize: number;
/** Fraction of a tile that must be entered to consider it active (0..1) */
private activeRatio: number;
/** Current chain of selected tiles */
private chain: DrawSumChain;
/** 2D array representing the game board (grid of tiles) */
private gameArray: DrawSumTile[][];
/** Current chain score (sum of selected tile values) */
private score: number;
/** Squared max distance allowed between pointer and tile center to accept selection */
private maxDistance: number;
/**
* Initializes a new DrawSum game instance.
*
* @param obj - Optional configuration (rows, columns, item types, cell size, active ratio)
*/
constructor(obj?: DrawSumOptions) {
obj = obj ?? {};
this.rows = obj.rows ?? 8;
this.columns = obj.columns ?? 7;
this.items = obj.items ?? 6;
this.cellSize = obj.cellSize ?? 128;
this.activeRatio = obj.activeRatio ?? 0.4;
this.maxDistance = Math.pow(this.cellSize * this.activeRatio, 2);
this.chain = new DrawSumChain();
this.score = 0;
this.gameArray = [];
for (let i: number = 0; i < this.rows; i++) {
this.gameArray[i] = [];
for (let j: number = 0; j < this.columns; j++) {
this.gameArray[i][j] = new DrawSumTile(Math.ceil(Math.random() * this.items));
}
}
}
/**
* Gets the numeric value stored at a given board coordinate.
*
* @param coordinate - Board coordinate
* @returns The tile value at the given coordinate
*/
getValueAt(coordinate: DrawSumCoordinate): number {
return this.gameArray[coordinate.row][coordinate.column].getValue();
}
/**
* Returns the current status of the board, including tile values and per-tile metadata.
*
* @returns An array of tile info describing board layout and data
*/
getBoardInfo(): DrawSumTileInfo[] {
const board: DrawSumTileInfo[] = [];
for (let i: number = 0; i < this.rows; i++) {
for (let j: number = 0; j < this.columns; j++) {
board.push({
coordinate: { row: i, column: j },
posX: j * this.cellSize,
posY: i * this.cellSize,
centerX : j * this.cellSize + this.cellSize / 2,
centerY : i * this.cellSize + this.cellSize / 2,
value: this.gameArray[i][j].getValue(),
customData: this.gameArray[i][j].getCustomData()
});
}
}
return board;
}
/**
* Gets the current score of the selection chain.
*
* @returns The total score accumulated during the current chain
*/
getScore(): number {
return this.score;
}
/**
* Attempts to pick a tile at screen coordinates (x, y).
* Adds the tile to the chain if valid, or backtracks if appropriate.
*
* @param x - Horizontal screen coordinate in pixels
* @param y - Vertical screen coordinate in pixels
* @returns The resulting move if a change occurred, otherwise false
*/
pickItemAtXY(x: number, y: number): DrawSumMove | false {
const row: number = Math.floor(y / this.cellSize);
const column: number = Math.floor(x / this.cellSize);
if (this.chain.getLength() > 0) {
const distanceFromCenter: number =
Math.pow(x - (column * this.cellSize + this.cellSize / 2), 2) +
Math.pow(y - (row * this.cellSize + this.cellSize / 2), 2);
if (distanceFromCenter > this.maxDistance) {
return false;
}
}
return this.pickItemAt({ row: row, column: column });
}
/**
* Attempts to pick a tile at a board coordinate.
* Adds the tile to the chain if valid, or backtracks if the same tile is the penultimate one.
*
* @param coordinate - Board coordinate to pick
* @returns The resulting move if a change occurred, otherwise false
*/
pickItemAt(coordinate: DrawSumCoordinate): DrawSumMove | false {
if (coordinate.row >= 0 && coordinate.row < this.rows && coordinate.column >= 0 && coordinate.column < this.columns) {
if (this.chain.getLength() == 0) {
this.score = 0;
}
if (this.chain.canContinue(coordinate)) {
this.chain.add(coordinate);
const secondLast: DrawSumCoordinate | false = this.chain.getSecondLast();
this.score += this.gameArray[coordinate.row][coordinate.column].getValue();
return {
coordinate: coordinate,
customData: this.gameArray[coordinate.row][coordinate.column].getCustomData(),
type: DrawSumMoveType.Added,
arrowCustomData: secondLast !== false ? this.gameArray[secondLast.row][secondLast.column].getCustomData() : false,
arrowDirection: this.chain.getDirection(this.chain.getLength() - 2)
}
}
if (this.chain.isBacktrack(coordinate)) {
const removedElement: DrawSumCoordinate = this.chain.backtrack() as DrawSumCoordinate;
const last: DrawSumCoordinate | false = this.chain.getLast();
this.score -= this.gameArray[removedElement.row][removedElement.column].getValue();
return {
coordinate: removedElement,
customData: this.gameArray[removedElement.row][removedElement.column].getCustomData(),
type: DrawSumMoveType.Removed,
arrowCustomData: last !== false ? this.gameArray[last.row][last.column].getCustomData() : false,
arrowDirection: this.chain.getDirection(this.chain.getLength() - 2)
}
}
}
return false;
}
/**
* Sets custom data for the tile at the specified coordinate.
*
* @param coordinate - Board coordinate
* @param customData - Arbitrary data to associate with the tile
*/
setCustomDataAt(coordinate: DrawSumCoordinate, customData: any): void {
this.gameArray[coordinate.row][coordinate.column].setCustomData(customData);
}
/**
* Gets the custom data associated with the tile at the specified coordinate.
*
* @param coordinate - Board coordinate
* @returns The tile's custom data
*/
getCustomDataAt(coordinate: DrawSumCoordinate): any {
return this.gameArray[coordinate.row][coordinate.column].getCustomData();
}
/**
* Processes the end of a move: flags selected tiles as empty, applies gravity, and replenishes.
*
* @returns Details about removed, moved, and newly created tiles
*/
handleMove(): DrawSumMoveResult {
const itemsToRemove: DrawSumCoordinate[] = [];
this.chain.export().forEach((coordinate: DrawSumCoordinate) => {
this.gameArray[coordinate.row][coordinate.column].setEmpty(true);
itemsToRemove.push(coordinate);
});
this.chain.clear();
return {
itemsToRemove: itemsToRemove,
itemsToArrange: this.arrangeBoard(),
itemsToReplenish: this.replenishBoard()
}
}
/**
* Swaps the tiles at two specified board positions.
*
* @param row - Row of the first tile
* @param column - Column of the first tile
* @param row2 - Row of the second tile
* @param column2 - Column of the second tile
*/
swapItems(row: number, column: number, row2: number, column2: number): void {
const temp: DrawSumTile = this.gameArray[row][column];
this.gameArray[row][column] = this.gameArray[row2][column2];
this.gameArray[row2][column2] = temp;
}
/**
* Counts how many empty tiles are below the given position.
*
* @param row - Row index
* @param column - Column index
* @returns The number of empty spaces below the tile
*/
emptySpacesBelow(row: number, column: number): number {
let result: number = 0;
if (row !== this.rows) {
for (let i: number = row + 1; i < this.rows; i++) {
if (this.gameArray[i][column].isEmpty()) {
result++;
}
}
}
return result;
}
/**
* Makes tiles fall down to fill empty spaces (gravity).
*
* @returns An array of movement details for each shifted tile
*/
arrangeBoard(): DrawSumFallingTile[] {
const result: DrawSumFallingTile[] = [];
for (let i: number = this.rows - 2; i >= 0; i--) {
for (let j: number = 0; j < this.columns; j++) {
const emptySpaces: number = this.emptySpacesBelow(i, j);
if (!this.gameArray[i][j].isEmpty() && emptySpaces > 0) {
this.swapItems(i, j, i + emptySpaces, j);
result.push({
coordinate: { row: i + emptySpaces, column: j },
deltaRow: emptySpaces,
start: i * this.cellSize,
end: (i + emptySpaces) * this.cellSize,
centerStart: i * this.cellSize + this.cellSize / 2,
centerEnd: (i + emptySpaces) * this.cellSize + this.cellSize / 2,
customData: this.getCustomDataAt({ row: i + emptySpaces, column: j })
});
}
}
}
return result;
}
/**
* Fills the top of the board with new tiles where needed.
*
* @returns An array of movement details for each new tile
*/
replenishBoard(): DrawSumFallingTile[] {
const result: DrawSumFallingTile[] = [];
for (let i: number = 0; i < this.columns; i++) {
if (this.gameArray[0][i].isEmpty()) {
const emptySpaces: number = this.emptySpacesBelow(0, i) + 1;
for (let j: number = 0; j < emptySpaces; j++) {
const randomValue: number = Math.ceil(Math.random() * this.items);
this.gameArray[j][i].setValue(randomValue);
this.gameArray[j][i].setEmpty(false);
result.push({
coordinate: { row: j, column: i },
deltaRow: emptySpaces,
start: - (emptySpaces - j) * this.cellSize,
end: (emptySpaces - (emptySpaces - j)) * this.cellSize,
centerStart: - (emptySpaces - j) * this.cellSize + this.cellSize / 2,
centerEnd: (emptySpaces - (emptySpaces - j)) * this.cellSize + this.cellSize / 2,
customData: this.getCustomDataAt({ row: j, column: i })
});
}
}
}
return result;
}
}
Notice how arrangeBoard and replenishBoard don’t just update the array, but also return pixel coordinates (start, end, centerStart, centerEnd) that your framework can use to animate tile movement.
index.ts – keeping it modular
To keep the code clean, all enums and types are defined in a separate file, re-exported via an index.ts. This way imports remain short and organized:
/**
* Barrel file for the DrawSum module.
*
* This file re-exports all the main DrawSum classes, types, and utilities
* so that they can be imported from a single entry point instead of importing
* each file individually.
*
* Example usage:
* ```ts
* import { DrawSum, DrawSumTile, DrawSumChain, DrawSumTypes } from './drawsum';
* ```
*/
export * from './drawsum';
export * from './drawsumTile';
export * from './drawsumChain';
export * from './drawsumTypes';
Now, let’s see how I built the Phaser example:
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.0" />
<script type="module" src="/src/main.ts"></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.
/**
* Configurable game options.
* Changing these values will affect gameplay.
*/
export const GameOptions : any = {
/** Overall game size, in pixels */
gameSize: {
/** Width of the game canvas */
width: 700,
/** Height of the game canvas */
height: 980
},
/** Game background color */
backgroundColor: 0x000000,
/** Size of each board cell, in pixels */
cellSize: 140,
/** Fade-out speed for destroyed items, in milliseconds */
destroySpeed: 400,
/** Falling speed of items, in milliseconds */
fallSpeed: 100
}
main.ts
This is where the game is created, with all Phaser related options.
/** Import Phaser game engine core */
import 'phaser';
/** Import scene that preloads all game assets */
import { PreloadAssets } from './scenes/preloadAssets';
/** Import main gameplay scene */
import { PlayGame } from './scenes/playGame';
/** Import game configuration options */
import { GameOptions } from './gameOptions';
/** Import main page stylesheet */
import './style.css';
/**
* Phaser game configuration object.
*
* @type {Phaser.Types.Core.GameConfig}
* @property {object} scale - Scaling and centering configuration.
* @property {Phaser.Scale.ScaleModes} scale.mode - Scaling mode (here set to `Phaser.Scale.FIT` to fit entire screen).
* @property {Phaser.Scale.Center} scale.autoCenter - Auto-centering mode (here `Phaser.Scale.CENTER_BOTH` to center horizontally and vertically).
* @property {string} scale.parent - ID of the HTML container element.
* @property {number} scale.width - Game width in pixels.
* @property {number} scale.height - Game height in pixels.
* @property {string | number} backgroundColor - Background color of the game.
* @property {Phaser.Scene[]} scene - Array of scenes used by the game.
*/
let configObject: Phaser.Types.Core.GameConfig = {
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
parent: 'thegame',
width: GameOptions.gameSize.width,
height: GameOptions.gameSize.height,
},
backgroundColor: GameOptions.backgroundColor,
scene: [
PreloadAssets,
PlayGame
]
};
/**
* Creates and starts a new Phaser game instance using the provided configuration.
* @param {Phaser.Types.Core.GameConfig} configObject - Game configuration object.
*/
new Phaser.Game(configObject);
scenes > preloadAssets.ts
Here we preload all assets to be used in the game.
import { GameOptions } from "../gameOptions";
/**
* Scene responsible for preloading all game assets before starting the main game.
* Extends the Phaser.Scene class.
*/
export class PreloadAssets extends Phaser.Scene {
/**
* Creates a new PreloadAssets scene instance.
* Scene key is set to `"PreloadAssets"`.
*/
constructor() {
super('PreloadAssets');
}
/**
* Loads all necessary game assets before the game starts.
* This method is automatically called by Phaser during the scene's preload phase.
*/
preload(): void {
/**
* Loads the gem spritesheet used for the main board tiles.
*
* - **Key**: `'gems'`
* - **Path**: `'assets/sprites/gems.png'`
* - **Frame size**: `GameOptions.cellSize` × `GameOptions.cellSize`
*
* Each frame represents a different type of gem displayed in the game grid.
*/
this.load.spritesheet('gems', 'assets/sprites/gems.png', {
frameWidth: GameOptions.cellSize,
frameHeight: GameOptions.cellSize
});
/**
* Loads the arrow spritesheet used for directional indicators during gameplay.
*
* - **Key**: `'arrows'`
* - **Path**: `'assets/sprites/arrows.png'`
* - **Frame size**: `GameOptions.cellSize * 3` × `GameOptions.cellSize * 3`
*
* Frames are used to display arrows pointing in straight or diagonal directions.
*/
this.load.spritesheet('arrows', 'assets/sprites/arrows.png', {
frameWidth: GameOptions.cellSize * 3,
frameHeight: GameOptions.cellSize * 3
});
}
/**
* Called once when the scene is created after preloading.
* Starts the "PlayGame" scene.
*/
create(): void {
this.scene.start("PlayGame");
}
}
scenes > playGame.ts
Main game file, all game logic is stored here.
/**
* Import game configuration and core DrawSum modules.
*/
import { GameOptions } from '../gameOptions';
import { DrawSum, DrawSumTileInfo, DrawSumMove, DrawSumMoveType, DrawSumMoveResult, DrawSumFallingTile } from '../drawsum';
/**
* Allowed arrow directions.
*/
export type ArrowDirection = 'up' | 'up-right' | 'right' |'down-right' | 'down' | 'down-left' | 'left' | 'up-left';
/**
* Configuration for each arrow direction:
* - `frame`: spritesheet frame index
* - `angle`: rotation in degrees
*/
export const arrowMap: Record<ArrowDirection, { frame: number; angle: number }> = {
'up': { frame: 0, angle: 270 },
'up-right': { frame: 1, angle: 0 },
'right': { frame: 0, angle: 0 },
'down-right':{ frame: 1, angle: 90 },
'down': { frame: 0, angle: 90 },
'down-left': { frame: 1, angle: 180 },
'left': { frame: 0, angle: 180 },
'up-left': { frame: 1, angle: 270 },
};
/**
* Main gameplay scene.
*
* This scene manages the core game loop, including:
* - Initializing the board using {@link DrawSum}
* - Handling user input (pointer events)
* - Updating the score display
* - Animating tile movements and interactions
*
* Extends Phaser.Scene, so it follows the Phaser lifecycle:
* - preload() ? asset loading (handled in PreloadAssets)
* - create() ? scene setup and initialization
*/
export class PlayGame extends Phaser.Scene {
/**
* Creates a new instance of the PlayGame scene.
*
* Calls the parent Phaser.Scene constructor with the scene key `'PlayGame'`,
* which is used by Phaser to reference and switch to this scene.
*/
constructor() {
super('PlayGame');
}
/**
* Instance of the DrawSum game logic.
* Manages the board, chains, moves, and scoring system.
*/
drawSum: DrawSum;
/**
* Text object used to display the current sum/score on screen.
*/
sumText: Phaser.GameObjects.Text;
/**
* Called once when the scene is created.
*/
create() : void {
/**
* Initializes scene state variables using Phaser's built-in Data Manager.
*
* @property {boolean} canPick - Whether the player is currently allowed to pick an item.
* @property {boolean} isDrawing - Whether the player is currently drawing a chain of items.
* @property {number} tweensRunning - Counter of active tweens currently running in the scene.
*/
this.data.set({
canPick: true,
isDrawing: false,
tweensRunning: 0,
});
/**
* Creates a new instance of the DrawSum game logic.
*
* @type {DrawSum}
* @property {number} rows - Number of rows in the game board.
* @property {number} columns - Number of columns in the game board.
* @property {number} items - Number of different item types available on the board.
* @property {number} cellSize - Size of each cell in pixels (taken from GameOptions).
*/
this.drawSum = new DrawSum({
rows: 5,
columns: 5,
items: 9,
cellSize: GameOptions.cellSize
});
/**
* Iterates through all board tiles returned by `drawSum.getBoardInfo()`
* and creates a visual representation for each tile.
*
* For every tile:
* - Creates a gem sprite at the tile's center (`itemSprite`) using the tile's value as frame index.
* - Creates an arrow sprite (`arrowSprite`) at the same position, initially hidden and placed above the gem (depth = 2).
* - Stores both sprites inside the tile's custom data, so they can be easily accessed later.
*/
this.drawSum.getBoardInfo().forEach((tile: DrawSumTileInfo) => {
const itemSprite: Phaser.GameObjects.Sprite = this.add.sprite(tile.centerX, tile.centerY, 'gems', tile.value - 1);
const arrowSprite: Phaser.GameObjects.Sprite = this.add.sprite(tile.centerX, tile.centerY, 'arrows');
arrowSprite.setDepth(2);
arrowSprite.visible = false;
this.drawSum.setCustomDataAt(tile.coordinate, {
itemSprite: itemSprite,
arrowSprite: arrowSprite
});
})
/**
* Creates the score text displayed on screen.
*
* Positioned horizontally at the center of the game canvas (`game.config.width / 2`)
* and vertically at 700px from the top. The text initially shows `"0"`.
*
* @type {Phaser.GameObjects.Text}
* @param {number} x - Horizontal position of the text (centered on the game width).
* @param {number} y - Vertical position of the text (700px from the top).
* @param {string} text - Initial text content ("0").
* @param {object} style - Text style configuration.
* @property {string} fontFamily - Font family used for the text ("Arial").
* @property {number} fontSize - Font size in pixels (256).
* @property {string} color - Hexadecimal color string ("#2394bc").
* @method setOrigin - Sets the origin point of the text (here centered horizontally and top-aligned).
*/
this.sumText = this.add.text(this.game.config.width as number / 2, 700, "0", {
fontFamily: "Arial",
fontSize: 256,
color: "#2394bc"
}).setOrigin(0.5, 0);
/**
* Registers a listener for the `pointerdown` event.
* Triggered when the player presses/clicks on the game canvas,
* starting the drawing (chain selection) process.
*
* @event Phaser.Input.InputPlugin#pointerdown
* @param {Phaser.Input.Pointer} pointer - The pointer input object containing event data.
*/
this.input.on('pointerdown', this.startDrawing, this);
/**
* Registers a listener for the `pointermove` event.
* Triggered when the player moves the pointer while pressing/clicking,
* allowing the chain to be extended across adjacent cells.
*
* @event Phaser.Input.InputPlugin#pointermove
* @param {Phaser.Input.Pointer} pointer - The pointer input object containing event data.
*/
this.input.on('pointermove', this.keepDrawing, this);
/**
* Registers a listener for the `pointerup` event.
* Triggered when the player releases the pointer (mouse up or touch end),
* stopping the drawing process and finalizing the chain.
*
* @event Phaser.Input.InputPlugin#pointerup
* @param {Phaser.Input.Pointer} pointer - The pointer input object containing event data.
*/
this.input.on('pointerup', this.stopDrawing, this);
}
/**
* Handles the beginning of a drawing action when the player presses down on the screen.
*
* - Checks if the player is currently allowed to pick (`canPick` flag in scene data).
* - If allowed, delegates the logic to `handleDrawing()` with the given pointer.
*
* @param {Phaser.Input.Pointer} pointer - The pointer (mouse or touch) event triggering the action.
*/
startDrawing(pointer: Phaser.Input.Pointer): void {
if (this.data.get('canPick')) {
this.handleDrawing(pointer);
}
}
/**
* Continues the drawing action while the player moves the pointer (mouse or touch).
*
* - Checks if a drawing action is currently active (`isDrawing` flag in scene data).
* - If so, continues processing the movement by calling `handleDrawing()`.
*
* @param {Phaser.Input.Pointer} pointer - The pointer event representing the player's movement.
*/
keepDrawing(pointer: Phaser.Input.Pointer): void {
if (this.data.get('isDrawing')) {
this.handleDrawing(pointer);
}
}
/**
* Handles the logic of drawing (selecting/unselecting cells) while the player interacts with the board.
*
* - Attempts to pick a cell based on the pointer coordinates via `pickItemAtXY`.
* - If a valid `DrawSumMove` is returned:
* - Updates the score text on screen.
* - Adjusts the appearance of the picked cell depending on whether it was **added** or **removed**:
* - **Added**: gem sprite becomes semi-transparent and the corresponding arrow is displayed.
* - **Removed**: gem sprite returns to full opacity and the corresponding arrow is hidden.
* - Updates control flags (`canPick`, `isDrawing`) to ensure the drawing action is properly tracked.
*
* @param {Phaser.Input.Pointer} pointer - The pointer event with the current x/y coordinates.
*/
handleDrawing(pointer: Phaser.Input.Pointer): void {
const pickedItem: DrawSumMove | false = this.drawSum.pickItemAtXY(pointer.x, pointer.y);
if (pickedItem !== false) {
this.sumText.setText(this.drawSum.getScore().toString());
if (pickedItem.type == DrawSumMoveType.Added) {
pickedItem.customData.itemSprite.setAlpha(0.5);
if (pickedItem.arrowCustomData !== false) {
const config : any = arrowMap[pickedItem.arrowDirection as ArrowDirection];
pickedItem.arrowCustomData.arrowSprite.setVisible(true).setFrame(config.frame).setAngle(config.angle);
}
}
else {
pickedItem.customData.itemSprite.setAlpha(1);
if (pickedItem.arrowCustomData !== false) {
pickedItem.arrowCustomData.arrowSprite.setVisible(false);
}
}
if (this.data.get('canPick')) {
this.data.set('canPick', false);
this.data.set('isDrawing', true);
}
}
}
/**
* Finalizes the drawing action when the player releases the pointer.
*
* - Ends the current chain selection if one is in progress (`isDrawing` flag).
* - Calls `handleMove()` from `DrawSum` to process the selected chain and compute:
* - `itemsToReplenish`: new cells to spawn and fall down.
* - `itemsToArrange`: existing cells that must fall down to fill empty spaces.
* - Fades out the sprites of the removed items (`itemsToFade`) with a tween animation.
* - Once fading completes:
* - Moves down existing items (`itemsToArrange`) using tweens proportional to their `deltaRow`.
* - Spawns and animates replenished items (`itemsToReplenish`) by resetting their initial position,
* restoring opacity, updating the sprite frame, and tweening them into the correct cell.
*/
stopDrawing(): void {
if (this.data.get('isDrawing')) {
this.data.set('isDrawing', false);
const moves: DrawSumMoveResult = this.drawSum.handleMove();
const itemsToFade: Phaser.GameObjects.Sprite[] = [];
moves.itemsToReplenish.forEach ((tileToFade: DrawSumFallingTile) => {
itemsToFade.push(tileToFade.customData.itemSprite);
tileToFade.customData.arrowSprite.setVisible(false);
});
this.tweens.add({
targets: itemsToFade,
alpha: 0,
duration: GameOptions.destroySpeed,
callbackScope: this,
onComplete: () => {
moves.itemsToArrange.forEach((tileToMoveDown: DrawSumFallingTile) => {
this.tweenItem([tileToMoveDown.customData.itemSprite, tileToMoveDown.customData.arrowSprite], tileToMoveDown.centerEnd, GameOptions.fallSpeed * Math.abs(tileToMoveDown.deltaRow))
});
moves.itemsToReplenish.forEach((movement: DrawSumFallingTile) => {
const data: any = this.drawSum.getCustomDataAt(movement.coordinate);
data.itemSprite.y = movement.centerStart;
data.itemSprite.setAlpha(1);
data.itemSprite.setFrame(this.drawSum.getValueAt(movement.coordinate) -1)
this.tweenItem([data.itemSprite, data.arrowSprite], movement.centerEnd, GameOptions.fallSpeed * Math.abs(movement.deltaRow))
})
}
});
}
}
/**
* Animates one or more sprites to fall to their target Y position.
*
* - Increments the `tweensRunning` counter before starting the tween, to keep track of active animations.
* - Creates a tween moving the provided sprites vertically to `destinationY` over the given `duration`.
* - When the tween completes:
* - Decrements `tweensRunning`.
* - If no tweens are still running (`tweensRunning == 0`), re-enables user interaction by setting `canPick` to true.
*
* @param {Phaser.GameObjects.Sprite[]} item - Array of sprites to animate (e.g., gem + arrow).
* @param {number} destinationY - Final Y coordinate the sprites should move to.
* @param {number} duration - Duration of the tween, in milliseconds.
*/
tweenItem(item: Phaser.GameObjects.Sprite[], destinationY: number, duration: number) {
this.data.inc('tweensRunning')
this.tweens.add({
targets: item,
y: destinationY,
duration: duration,
callbackScope: this,
onComplete: function() {
this.data.inc('tweensRunning', -1);
if (this.data.get('tweensRunning') == 0) {
this.data.set('canPick', true);
}
}
});
}
}
With this class, you can start building your math draw game. Download the full source code along with the Vite project. Don’t know what I am talking about? There’s a free minibook to get you started.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.