One of my CodePen pens got real attention. The demo of Connect Four with pure JavaScript from 2014: https://codepen.io/osbulbul/pen/ngJdYy

I am still not sure why it has more than 50,000 views and a lot of likes. But why not build a better version with more modern libraries, a better user experience, and a fully finished. So today, we will build this:

Development Environment

Of course, we will use JavaScript again to make this game. But this time, I will use the game engine, which is PhaserJS. To make development with PhaserJS, we also need a web server and bundler to manage our files. Luckily, we have ViteJS to do both of them. And I will use VSCode as my code editor.

Let’s start with creating our project with Vite. Open up your terminal, go to your projects directory, and run this command:

npm create vite@latest

Project Setup

It will ask for your project name; give any. Choose “Vanilla” as the framework and JavaScript as the variant. Now the CLI tool should create a folder with your project name.

Go to your project directory, like this:

cd connect4

install npm dependencies:

npm install

Don’t forget to add a phaser too:

npm install phaser

and now you can run your web server:

npm run dev

The terminal should show you the project preview URL. Open it in your browser. Also, open your project folder in your code editor. And delete unnecessary files, add needed folders. Your folder structure should look like this:

/ assets
/ lib
/ scenes
.gitignore
index.html
main.js
package-lock.json
package.json
INFO

node_modules folder can be visible or hidden based on your preferences.

Change the index.html file content like this:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Connect 4</title>
        <style>
            *{
                margin: 0;
                padding: 0;
            }
        </style>
    </head>
    <body>
        <script type="module" src="/main.js"></script>
    </body>
</html>

Basically, it’s a simple HTML page; we removed the spacing with a style tag and imported our JS file with a script tag.

And change the main.js file content like this:

import Phaser from "phaser";

const game = new Phaser.Game({
    type: Phaser.AUTO, // Phaser.AUTO will use WebGL if available, otherwise it will use Canvas
    width: 1000,
    height: 1200,
    backgroundColor: "#0f0f0f", // Background color of the game
    roundPixels: false, // Round pixels to prevent subpixel rendering
    scale: {
        mode: Phaser.Scale.FIT, // Fit the game to the screen
        autoCenter: Phaser.Scale.CENTER_BOTH // Center the game on the screen
    },
});

Now, you need to see the black box on the page. In this code, we imported the Phaser library and created a new game. Type defines the phaser renderer; it can be canvas, webgl, headless, or auto.

I chose the width, height, and background color to fit perfectly for my blog. But you can change them as you wish. roundPixels specify positioning game objects in whole-integer positions. It’s good for pixel art games, but it causes sharp corners, and we want smooth visuals for our game.

For scale options, we set mode to FIT, which will scale our game to fit the available width and height without breaking the aspect ratio. And center our game according to parent because we set the autoCenter option.

You can check all available options at Phaser Docs.

Create Scenes

We need three scenes, and we will create them in the scenes folder. Let’s start with Preload.js, and its content will be:

export default class Preload extends Phaser.Scene {
    constructor() {
        super("Preload");
    }

    preload() {
        // Load assets
    }
    
    create() {
        // Load custom font
        const newFontFace = new FontFace('Nunito', 'url(../assets/Nunito-Black.ttf)');
        document.fonts.add(newFontFace);
        newFontFace.load().then(() => {
            this.scene.start("Menu");
        });
    }
}

We created a scene class while extending Phaser.Scene, and we specified its name to use in Phaser in the constructor method.

Normally, we load assets using the preload method. For example, to load any image, you can use load methods like this:

preload() {
    // Load assets
    this.load.image('key', 'path/to/file.png');
}

But because Phaser doesn’t have a native font loader, we loaded our font file using the create method. And after it’s loaded, we switch to the menu scene. If you want to use any assets, you can add them to the preload method like shown above.

Don’t forget to add your font file to assets folder.

Now, let’s create Menu.js:

export default class Menu extends Phaser.Scene {
    constructor() {
        super("Menu");
    }

    create() {
        
    }
}

You already know what this code does. Let’s use our create method.

INFO

The create method runs once while creating the scene.

// Logo
this.add.text(500, 250, "Connect4", {
    fontFamily: "Nunito",
    fontSize: 98,
    color: "#e6e6e6",
    align: "center"
}).setOrigin(0.5);

You can add the title like above. And after that, let’s add our scenes to our game so we can see them on the page. Change the main.js file like this:

import Phaser from "phaser";
import Preload from "./scenes/Preload";
import Menu from "./scenes/Menu";

const game = new Phaser.Game({
    type: Phaser.AUTO, // Phaser.AUTO will use WebGL if available, otherwise it will use Canvas
    width: 1000,
    height: 1200,
    backgroundColor: "#0f0f0f", // Background color of the game
    roundPixels: false, // Round pixels to prevent subpixel rendering
    scale: {
        mode: Phaser.Scale.FIT, // Fit the game to the screen
        autoCenter: Phaser.Scale.CENTER_BOTH // Center the game on the screen
    },
    scene: [Preload, Menu]
});

I imported related scenes and added them to the game with the scene option. You can check the browser, and you should see the logo like this:

Return back to the menu scene and add some cool animation:

// Graphics
const disc1 = this.add.circle(440, 500, 50, 0xE06C75);
const disc2 = this.add.circle(560, 500, 50, 0xE5C07B);

this.tweens.add({
    targets: disc1,
    x: 560,
    duration: 200,
    ease: 'Sine.easeInOut',
    yoyo: true,
    repeat: -1,
    repeatDelay: 1000
});

this.tweens.add({
    targets: disc2,
    x: 440,
    duration: 200,
    ease: 'Sine.easeInOut',
    yoyo: true,
    repeat: -1,
    repeatDelay: 1000
});

We added two circles and gave them two tweens. Now, let’s also add our start game button:

// Play button
const playButtonBackground = this.add.graphics();
playButtonBackground.fillStyle(0xC678DD, 1);
playButtonBackground.fillRoundedRect(400, 655, 200, 100, 20);

const playButton = this.add.text(500, 700, "start", {
    fontFamily: "Nunito",
    fontSize: 52,
    color: "#e6e6e6",
    align: "center"
}).setOrigin(0.5);

playButtonBackground.setInteractive(new Phaser.Geom.Rectangle(400, 655, 200, 100), Phaser.Geom.Rectangle.Contains);
playButtonBackground.on("pointerup", () => {
    this.scene.start("Game");
});

You can check final version of Menu scene from Github: Menu.js

If you are using any image, you don’t need to give any parameters for the setInteractive method. But for graphics, we need to specify the interactive area.

And when we click the button, we switch to the game scene. But we haven’t created it yet. So let’s create it in the scenes folder as Game.js:

export default class Game extends Phaser.Scene {
    constructor() {
        super("Game");
    }

    create() {
        
    }

    update() {
        
    }
}

Our template is ready; we can start to fill the create method first.

INFO

The update method runs repeatedly. So if your game runs on 60FPS, the update method will run 60 times per second.

To create UI elements, I will create a new class in the lib folder. This is the content of the lib/Ui.js file:

export default class Ui{
    constructor(scene){
        this.scene = scene;
    }
}

Next, we will add the addInfos method to the Ui.js file to add game information to the page:

addInfos(){
    let userDisc = this.scene.add.circle(100, 100, 30, 0xE06C75);
    let userText = this.scene.add.text(150, 70, "User", {
        fontFamily: "Nunito",
        fontSize: 48,
        color: "#e6e6e6",
        align: "center"
    });

    let aiDisc = this.scene.add.circle(900, 100, 30, 0xE5C07B);
    let aiText = this.scene.add.text(800, 70, "AI", {
        fontFamily: "Nunito",
        fontSize: 48,
        color: "#e6e6e6",
        align: "center"
    });

    // Helper Text
    this.helperText = this.scene.add.text(500, 200, "Your turn!", {
        fontFamily: "Nunito",
        fontSize: 48,
        color: "#e6e6e6",
        align: "center"
    }).setOrigin(0.5);
}

Import our Ui class on the Game.js file game scene like this:

import Ui from "../lib/Ui";

And call it in the create method of the game scene:

this.ui = new Ui(this);
this.ui.addInfos();

Don’t forget to add our game scene to the game config too, so the final version of main.js will be like this:

import Phaser from "phaser";
import Preload from "./scenes/Preload";
import Menu from "./scenes/Menu";
import Game from "./scenes/Game";

const game = new Phaser.Game({
    type: Phaser.AUTO, // Phaser.AUTO will use WebGL if available, otherwise it will use Canvas
    width: 1000,
    height: 1200,
    backgroundColor: "#0f0f0f", // Background color of the game
    roundPixels: false, // Round pixels to prevent subpixel rendering
    scale: {
        mode: Phaser.Scale.FIT, // Fit the game to the screen
        autoCenter: Phaser.Scale.CENTER_BOTH // Center the game on the screen
    },
    scene: [Preload, Menu, Game]
});

The game should look like this on your page:

Now, we need to add our board to play the actual game. I want to create another class to manage the board. Create a Board.js file in the lib:

export default class Board{
    constructor(scene){
        this.scene = scene;

        // keep track of the board state
        this.status = [
            [0, 0, 0, 0, 0, 0, 0],
            [0, 0, 0, 0, 0, 0, 0],
            [0, 0, 0, 0, 0, 0, 0],
            [0, 0, 0, 0, 0, 0, 0],
            [0, 0, 0, 0, 0, 0, 0],
            [0, 0, 0, 0, 0, 0, 0],
        ];
    }

    create(){
        // create board graphics
        const board = this.scene.add.graphics();
        board.fillStyle(0x61AFEF, 1);
        board.fillRoundedRect(50, 350, 900, 800, 20);
        board.setDepth(1);

        // create board mask
        const boardMask = this.scene.add.graphics().setVisible(false);
        boardMask.fillStyle(0xffffff, 1);
        const mask = boardMask.createGeometryMask().setInvertAlpha(true);
        board.setMask(mask);

        // create board holes
        for (let row = 0; row < 6; row++) {
            for (let col = 0; col < 7; col++) {
                boardMask.fillCircle(150 + col * 120, 350 + row * 120 + 110, 50);
            }
        }
    }
}

In this code, we first create our board as an array so we can keep track of board status and check if winning positions happen.

Then, in the create function, I created big rectangle graphics and cut circles on them with a mask. Import the Board class in the game scene, and use it like this:

import Board from "../lib/Board";
this.board  = new Board(this);
this.board.create();

And the result should look like this:

Our game starts to shape up! Let’s add some interactivity. In our game scene create function, let’s add these codes:

this.allowInteraction = false;
this.targetX = false;
this.changeTurn('player');

this.input.on("pointermove", (pointer)=>{
    if(!this.allowInteraction)return;

    if(this.turn == 'player'){
        let newX = this.limitX(pointer.x);
        this.targetX = newX;
    }
});

We created the allowInteraction flag, and we will use it to allow user interaction if the turn belongs to the user. And we created targetX, and we will use it to move the user-held disc. You will understand better in a sec.

But we need to create two more methods in the game scene: changeTurn to specify whose turn it is right now and limitX to specify where the user-held disc can go.

changeTurn(side){
    this.turn = side;
    if(side == 'player'){
        this.ui.helperText.setText("Your turn!");

        this.discOnHand = this.add.circle(510, 285, 50, 0xE06C75);
        this.discOnHand.setDepth(0);
        this.time.delayedCall(150, ()=>{
            this.allowInteraction = true;
        });
    } else {
        this.ui.helperText.setText("AI's turn!");

        this.discOnHand = this.add.circle(510, 285, 50, 0xE5C07B);
        this.discOnHand.setDepth(0);
    }
}
limitX(x){
    const positions = [150, 270, 390, 510, 630, 750, 870];
    let closest = positions.reduce((prev, curr) => {
        // return the closest value to x
        return (Math.abs(curr - x) < Math.abs(prev - x) ? curr : prev);
    });
    return closest;
}

The position array variable of the limitX function holds the x position of columns on our board. If you change the game or board dimensions, it can be different for your game.

And we need to add these to your update method for the game scene:

update() {
    if(this.turn == 'player' && this.targetX !== false && this.allowInteraction){
        if(this.targetX > this.discOnHand.x){
            this.discOnHand.x += 15;
        } else if(this.targetX < this.discOnHand.x){
            this.discOnHand.x -= 15;
        }
    }
}

You can check final version of Game scene from Github: Game.js

Now, you should see some interactivity.

To add actual game play, we also need to improve dropping discs to the board. So let’s add a pointerup event to the create method of the game scene:

this.input.on("pointerup", (pointer) => {
    if(!this.allowInteraction)return;

    if(this.turn == 'player'){
        this.allowInteraction = false;
        this.discOnHand.x = this.limitX(pointer.x);
        this.board.dropDisc(this.discOnHand, ()=>{
            this.changeTurn('ai');
        });
    }
});

And we need to add the dropDisc function to the board class:

dropDisc(disc, callback){
    let col = this.getColumn(disc.x);
    let row = this.getEmptyRow(col);
    if(row == -1)return;

    let y = 350 + row * 120 + 110;
    this.scene.tweens.add({
        targets: disc,
        y: y,
        duration: 500,
        ease: "Bounce",
        onComplete: ()=>{

            if(this.scene.turn == 'player'){
                this.status[row][col] = 1;
            } else {
                this.status[row][col] = 2;
            }

            const result = this.checkWin();

            if(result.length){
                this.scene.ui.helperText.setText(this.scene.turn == 'player' ? "Congratulations!" : "Game Over!");                    
            } else {
                callback();
            }
        }
    });
}

We are first finding available space with helper functions, then using tween to show drop animation. Then we are updating our status array according to who is in turn. So then we can use the checkWin function.

Also, we need to add three more methods to the board class to use in the above method.

getColumn(x){
    return Math.floor((x - 50) / 120);
}

getEmptyRow(col){
    for (let row = 5; row >= 0; row--) {
        if(this.status[row][col] == 0)return row;
    }
    return -1;
}

As you can guess, getColumn returns that x’s corresponding column. And getEmptyRow returns the first empty row of a specific column. Let’s add the checkWin function too.

checkWin(){
    // check vertical or horizontal win
    let result = [];
    let player = this.scene.turn == 'player' ? 1 : 2;

    // check vertical
    for (let col = 0; col < 7; col++) {
        result = [];
        for (let row = 0; row < 6; row++) {
            if(this.status[row][col] == player){
                result.push({row: row, col: col});
                if(result.length >= 4){
                    return result;
                }
            } else {
                result = [];
            }
        }
    }

    // check horizontal
    for (let row = 0; row < 6; row++) {
        result = [];
        for (let col = 0; col < 7; col++) {
            if(this.status[row][col] == player){
                result.push({row: row, col: col});
                if(result.length >= 4){
                    return result;
                }
            } else {
                result = [];
            }
        }
    }

    // check downward diagonals (top-left to bottom-right)
    for (let col = 0; col <= 7 - 4; col++) { // Ensures there's space for at least 4 discs
        for (let row = 0; row <= 6 - 4; row++) { // Similar boundary for rows
            result = [];
            for (let i = 0; i < 4; i++) { // Only need to check the next 4 spots
                if (this.status[row + i][col + i] == player) {
                    result.push({row: row + i, col: col + i});
                    if (result.length >= 4) {
                        return result;
                    }
                } else {
                    break;
                }
            }
        }
    }

    // check upward diagonals (bottom-left to top-right)
    for (let col = 0; col <= 7 - 4; col++) { // Ensures there's space for at least 4 discs
        for (let row = 3; row < 6; row++) { // Starts from row 3 to ensure space for 4 upward
            result = [];
            for (let i = 0; i < 4; i++) { // Only need to check the next 4 spots
                if (this.status[row - i][col + i] == player) {
                    result.push({row: row - i, col: col + i});
                    if (result.length >= 4) {
                        return result;
                    }
                } else {
                    break;
                }
            }
        }
    }

    return false;
}

Okay, that seems complicated. But actually not, if you check line by line. Check out this part:

// check vertical
for (let col = 0; col < 7; col++) {
    result = [];
    for (let row = 0; row < 6; row++) {
        if(this.status[row][col] == player){
            result.push({row: row, col: col});
            if(result.length >= 4){
                return result;
            }
        } else {
            result = [];
        }
    }
}

Basically, we are just looking at every column, row by row, to see if there are four or more of the of the same disc in order. We are also checking the same thing, but horizontally too. Also, we are checking diagonally.

Now we need to add our AI, and I will make another class for it. Create an Ai.js file:

export default class Ai{
    constructor(scene){
        this.scene = scene;
    }

    makeMove(board){
        this.board = board;
        this.scene.time.delayedCall(1000, ()=>{
            let decidedPos = this.think();
            this.scene.tweens.add({
                targets: this.scene.discOnHand,
                duration: 150,
                x: decidedPos.x,
                onComplete: ()=>{
                    board.dropDisc(this.scene.discOnHand, ()=>{
                        this.scene.changeTurn('player');
                    });
                }
            });
        });
    }

    think(){
        return {x: 150, y: 240};
    }
}

It’s basically going to move the disc to the first column and drop there. But how we will use our ai? We need to update the create method of the game scene to add this code:

import Ai from "../lib/Ai";
this.ai = new Ai(this);

And modify the changeTurn function to use AI on the else code block:

changeTurn(side){
    this.turn = side;
    if(side == 'player'){
        this.ui.helperText.setText("Your turn!");

        this.discOnHand = this.add.circle(510, 285, 50, 0xE06C75);
        this.discOnHand.setDepth(0);
        this.time.delayedCall(150, ()=>{
            this.allowInteraction = true;
        });
    } else {
        this.ui.helperText.setText("AI's turn!");

        this.discOnHand = this.add.circle(510, 285, 50, 0xE5C07B);
        this.discOnHand.setDepth(0);
        this.ai.makeMove(this.board);
    }
}

Now, you can play with AI, but winning will be pretty easy :)

AI Code

Let’s code our AI class. Change our thinking method like this:

think(){
    let possibleMoves   = this.getPossibleColumns();
    let bestMove        = false;

    for(let move of possibleMoves){
        if(this.isWinningMove(move)){
            bestMove = move;
            break;
        }

        if(this.isBlockingMove(move)){
            bestMove = move;
            break;
        }
    }

    if(!bestMove){
        bestMove = possibleMoves[Math.floor(Math.random() * possibleMoves.length)];
    }

    let x = 150 + bestMove.col * 120;
    let y = 350 - 110;
    return {x: x, y: y};
}

So first, we get all possible columns. Which means we have seven possible moves at most. Then we check all possible moves to make sure there are winning moves. If not, is there any blocking move that prevents the player winning? If not, choose a random move.

Let’s define the getting possible columns function:

getPossibleColumns(){
    let possibleMoves = [];
    for (let col = 0; col < 7; col++) {
        let row = this.board.getEmptyRow(col);
        if(row != -1){
            possibleMoves.push({col: col, row: row});
        }
    }
    return possibleMoves;
}

We use our board class methods to get an empty row for every column. And this is isWinningMove method:

isWinningMove(move){
    // check vertical or horizontal win
    let count = 1;
    let row = move.row;
    let col = move.col;
    let player = this.scene.turn == 'player' ? 1 : 2;

    // check vertical
    for (let i = row + 1; i < 6; i++) {
        if(this.board.status[i][col] == player)count++;
        else break;
    }
    if(count >= 4)return true;

    // check horizontal
    count = 1;
    for (let i = col + 1; i < 7; i++) {
        if(this.board.status[row][i] == player)count++;
        else break;
    }
    for (let i = col - 1; i >= 0; i--) {
        if(this.board.status[row][i] == player)count++;
        else break;
    }
    if(count >= 4)return true;

    // check diagonal
    count = 1;
    let i = row + 1;
    let j = col + 1;
    while(i < 6 && j < 7){
        if(this.board.status[i][j] == player)count++;
        else break;
        i++;
        j++;
    }
    i = row - 1;
    j = col - 1;
    while(i >= 0 && j >= 0){
        if(this.board.status[i][j] == player)count++;
        else break;
        i--;
        j--;
    }
    if(count >= 4)return true;

    count = 1;
    i = row + 1;
    j = col - 1;
    while(i < 6 && j >= 0){
        if(this.board.status[i][j] == player)count++;
        else break;
        i++;
        j--;
    }
    i = row - 1;
    j = col + 1;
    while(i >= 0 && j < 7){
        if(this.board.status[i][j] == player)count++;
        else break;
        i--;
        j++;
    }
    if(count >= 4)return true;

    return false;
}

Again, this seems complicated, but it’s similar to our checkWin function; it’s just checking if there are any winning conditions.

And isBlockingMove method:

isBlockingMove(move){
    // check vertical or horizontal win
    let count = 1;
    let row = move.row;
    let col = move.col;
    let player = this.scene.turn == 'player' ? 2 : 1;

    // check vertical
    for (let i = row + 1; i < 6; i++) {
        if(this.board.status[i][col] == player)count++;
        else break;
    }
    if(count >= 4)return true;

    // check horizontal
    count = 1;
    for (let i = col + 1; i < 7; i++) {
        if(this.board.status[row][i] == player)count++;
        else break;
    }
    for (let i = col - 1; i >= 0; i--) {
        if(this.board.status[row][i] == player)count++;
        else break;
    }
    if(count >= 4)return true;

    // check diagonal
    count = 1;
    let i = row + 1;
    let j = col + 1;
    while(i < 6 && j < 7){
        if(this.board.status[i][j] == player)count++;
        else break;
        i++;
        j++;
    }
    i = row - 1;
    j = col - 1;
    while(i >= 0 && j >= 0){
        if(this.board.status[i][j] == player)count++;
        else break;
        i--;
        j--;
    }
    if(count >= 4)return true;

    count = 1;
    i = row + 1;
    j = col - 1;
    while(i < 6 && j >= 0){
        if(this.board.status[i][j] == player)count++;
        else break;
        i++;
        j--;
    }
    i = row - 1;
    j = col + 1;
    while(i >= 0 && j < 7){
        if(this.board.status[i][j] == player)count++;
        else break;
        i--;
        j++;
    }
    if(count >= 4)return true;

    return false;
}

Now, the game should be more challenging:

Our game is working pretty well now, but we still need to add a better ending and some restart buttons.

End Screen

We need to modify the dropDisc method of the board class a little bit:

dropDisc(disc, callback){
    let col = this.getColumn(disc.x);
    let row = this.getEmptyRow(col);
    if(row == -1)return;

    let y = 350 + row * 120 + 110;
    this.scene.tweens.add({
        targets: disc,
        y: y,
        duration: 500,
        ease: "Bounce",
        onComplete: ()=>{

            if(this.scene.turn == 'player'){
                this.status[row][col] = 1;
            } else {
                this.status[row][col] = 2;
            }

            const result = this.checkWin();

            if(result.length){
                this.scene.ui.helperText.setText(this.scene.turn == 'player' ? "Congratulations!" : "Game Over!");
                if(this.scene.turn == 'player'){
                    this.scene.ui.fireworks();
                    this.scene.time.delayedCall(2500, ()=>{
                        this.scene.ui.addRestartButton();
                    });
                } else {
                    this.scene.time.delayedCall(500, ()=>{
                        this.scene.ui.addRestartButton();
                    });
                }
                this.drawWinLine(result);                    
            } else {
                callback();
            }
        }
    });
}

So we added these lines:

if(this.scene.turn == 'player'){
    this.scene.ui.fireworks();
    this.scene.time.delayedCall(2500, ()=>{
        this.scene.ui.addRestartButton();
    });
} else {
    this.scene.time.delayedCall(500, ()=>{
        this.scene.ui.addRestartButton();
    });
}
this.drawWinLine(result);

First, we need to add the drawWinLine method to our board class, like this:

drawWinLine(result){
    let glowColor = this.scene.turn == 'player' ? 0x00ff00 : 0xff0000;
    let lineColor = this.scene.turn == 'player' ? 0x98C379 : 0xE06C75;
    let line = this.scene.add.graphics();
    line.setDepth(2);

    let first = result[0];
    let last = result[result.length - 1];

    let x1 = 150 + first.col * 120;
    let y1 = 350 + first.row * 120 + 110;
    line.x2 = x1;
    line.y2 = y1;
    let x2 = 150 + last.col * 120;
    let y2 = 350 + last.row * 120 + 110;
    
    // draw line from first to last disc animated
    this.scene.tweens.add({
        targets: line,
        duration: 500,
        x2: x2,
        y2: y2,
        onUpdate: ()=>{
            line.clear();

            line.fillStyle(lineColor, 1);
            line.fillCircle(x1, y1, 10);

            line.lineStyle(20, lineColor, 1);
            line.beginPath();
            line.moveTo(x1, y1);
            line.lineTo(line.x2, line.y2);
            line.strokePath();
        },
        onComplete: ()=>{
            line.fillCircle(x2, y2, 10);
        }
    });
    


    const effect = line.postFX.addGlow(glowColor, 0);
    this.scene.tweens.add({
        targets: effect,
        duration: 500,
        outerStrength: 5,
        yoyo: true,
        repeat: -1,
        ease: "Sine.easeInOut"
    });

}

And we need to add fireworks and addRestartButton methods to our UI class:

fireworks(){
    let graphics = this.scene.make.graphics({ x: 0, y: 0, add: false });
    graphics.fillStyle(0xffffff, 1);
    graphics.fillCircle(4, 4, 4); // x, y, radius
    graphics.generateTexture('spark', 8, 8);
    graphics.destroy();

    this.scene.time.addEvent({
        delay: 50,
        repeat: 50,
        callback: ()=>{
            this.explode(Math.random() * 1000, Math.random() * 600);
        },
    });
}

addRestartButton(){
    const overlay = this.scene.add.rectangle(0, 0, 1000, 1200, 0x000000, 0.5).setOrigin(0);
    overlay.setDepth(3);

    // Restart button
    const restartButtonBackground = this.scene.add.graphics();
    restartButtonBackground.setDepth(4);
    restartButtonBackground.fillStyle(0xC678DD, 1);
    restartButtonBackground.fillRoundedRect(400, 655, 240, 100, 20);
    
    const restartButton = this.scene.add.text(520, 700, "restart", {
        fontFamily: "Nunito",
        fontSize: 52,
        color: "#e6e6e6",
        align: "center"
    }).setOrigin(0.5);
    restartButton.setDepth(5);

    restartButtonBackground.setInteractive(new Phaser.Geom.Rectangle(400, 655, 200, 100), Phaser.Geom.Rectangle.Contains);
    restartButtonBackground.on("pointerdown", () => {
        this.scene.scene.start("Game");
    });
}

But also, we need to add the explode method to the UI class again:

explode(x, y){
    const hsv = Phaser.Display.Color.HSVColorWheel(); // get an array of color objects
    const tint = hsv.map(entry => entry.color); // get an array of color values

    let particles = this.scene.add.particles(x, y, 'spark', {
        speed: { min: -200, max: 200 },
        angle: { min: 0, max: 360 },
        scale: { start: 1, end: 0 },
        blendMode: 'ADD',
        lifespan: 1500,
        gravityY: 300,
        quantity: 25,
        duration: 50,
        tint: tint
    });
    particles.setDepth(2);
}
INFO

You can check complete game codes from Github Repo

Now, our final result. The game should look like this: