Tic-Tac-Toe PhaserJS Tutorial #

In this tutorial you will be creating XOXO, an online tic-tac-toe game, using PhaserJS and your Nakama server.

This game and tutorial highlight the powerful authentication, matchmaking, and real-time multiplayer features of Nakama.

The complete tutorial source code is available on GitHub.

Prerequisites #

To easily follow along with this tutorial, perform the following before proceeding:

Running the Nakama server #

In order to run the XOXO game you must first make sure Nakama is running.

Once you’ve installed Docker and cloned the Nakama Project Template, navigate to the folder you cloned the repository to and run the following command:

1
docker compose up

Configuring your JavaScript framework #

Here you will install the TypeScript dependencies required for this project, transpile your TypeScript code to JavaScript, and add PhaserJS to your Svelte JavaScript framework.

  1. Install NPM to manage your dependencies. From your terminal window:
1
npm install
  1. Before starting your Nakama server, transpile the TypeScript code to JS:
1
npx tsc
  1. Add the PhaserJS script tag to your index.html file:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
        <meta charset='utf-8'>
        <meta name='viewport' content='width=device-width,initial-scale=1'>
        <title>Svelte app</title>
        <link rel='icon' type='image/png' href='/favicon.png'>
        <link rel='stylesheet' href='/global.css'>
        <link rel='stylesheet' href='/build/bundle.css'>
        // PhaserJS script tag
        <script src="//cdn.jsdelivr.net/npm/phaser@3.54.0/dist/phaser.min.js"></script>
        <script defer src='/build/bundle.js'></script>
</head>
<body>
</body>
</html>
  1. You can now run your application locally by running:
1
npm run dev

Your application will be available at localhost:5000.

Building the UI #

  1. Start by adding the PhaserJS config to your App.svelte file (your game’s main entry point, imported in your main.js file):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script>
        import MainMenu from "./scenes/MainMenu";
        import Matchmaking from "./scenes/Matchmaking";
        import InGame from "./scenes/InGame";
        import CONFIG from "./config";
        const config = {
                type: Phaser.AUTO,
                width: CONFIG.WIDTH,
                height: CONFIG.HEIGHT,
                backgroundColor: "#FF4C4C",
                scene: [MainMenu, Matchmaking, InGame],
        };
        new Phaser.Game(config);
</script>
1
2
3
4
5
6
7
8
import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {}
});

export default app;
  1. Next, create a config.js file for you game settings. In this case, the height and width of the game canvas:
1
2
3
4
5
const CONFIG = {
      WIDTH: 414,
      HEIGHT: 736
}
export default CONFIG
  1. Now you can start creating the game’s Phaser scenes to group related logic. For our game we have three, the main menu, matchmaking, and in game scenes:

MainMenu.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

// ...

    create() {
        Nakama.authenticate()

        // Create the Welcome banner
        this.add
            .text(CONFIG.WIDTH / 2, 75, "Welcome to", {
                fontFamily: "Arial",
                fontSize: "24px",
            })
            .setOrigin(0.5);

        this.add
            .text(CONFIG.WIDTH / 2, 123, "XOXO", {
                fontFamily: "Arial",
                fontSize: "72px",
            })
            .setOrigin(0.5);

        this.add.grid(
            CONFIG.WIDTH / 2,
            CONFIG.HEIGHT / 2,
            300,
            300,
            100,
            100,
            0xffffff,
            0,
            0xffca27
        );

        // Create a button to start the game
        const playBtn = this.add
            .rectangle(CONFIG.WIDTH / 2, 625, 225, 70, 0xffca27)
            .setInteractive({ useHandCursor: true });

        const playBtnText = this.add
            .text(CONFIG.WIDTH / 2, 625, "Begin", {
                fontFamily: "Arial",
                fontSize: "36px",
            })
            .setOrigin(0.5);

        playBtn.on("pointerdown", () => {
            Nakama.findMatch()
            this.scene.start("in-game");
        });

        // ...
    }
}

Matchmaking.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// ...

    create() {
        this.add
            .text(CONFIG.WIDTH / 2, 125, "Searching for an opponent...", {
                fontFamily: "Arial",
                fontSize: "24px",
            })
            .setOrigin(0.5);

        this.anims.create({
            key: "spinnerAnimation",
            frames: this.anims.generateFrameNumbers("spinner"),
            frameRate: 30,
            repeat: Phaser.FOREVER,
        });

        this.add
            .sprite(CONFIG.WIDTH / 2, CONFIG.HEIGHT / 2, "spinner")
            .play("spinnerAnimation")
            .setScale(0.5);
    }
}

InGame.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

// ...

    updateBoard(board) {
        board.forEach((element, index) => {
            let newImage = this.INDEX_TO_POS[index]

            if (element === 1) {
                this.phaser.add.image(newImage.x, newImage.y, "O");
            } else if (element === 2) {
                this.phaser.add.image(newImage.x, newImage.y, "X");
            }
        })
    }

    updatePlayerTurn() {
        this.playerTurn = !this.playerTurn

        if (this.playerTurn) {
            this.headerText.setText("Your turn!")
        } else {
            this.headerText.setText("Opponents turn!")
        }
    }

    setPlayerTurn(data) {
        let userId = localStorage.getItem("user_id");
        if (data.marks[userId] === 1) {
            this.playerTurn = true;
            this.playerPos = 1;
            this.headerText.setText("Your turn!")
        } else {
            this.headerText.setText("Opponents turn!")
        }
    }

    endGame(data) {
        this.updateBoard(data.board)

        if (data.winner === this.playerPos) {
            this.headerText.setText("Winner!")
        } else {
            this.headerText.setText("You loose :(")
        }
    }

    nakamaListener() {
        Nakama.socket.onmatchdata = (result) => {
            switch (result.op_code) {
                case 1:
                    this.gameStarted = true;
                    this.setPlayerTurn(result.data)
                    break;
                case 2:
                    console.log(result.data)
                    this.updateBoard(result.data.board)
                    this.updatePlayerTurn()
                    break;
                case 3:
                    this.endGame(result.data)
                    break;
            }
        };
    }

    // ...

        // Register the player move in the correct square
        this.nakamaListener()

        this.add
            .rectangle(
                gridCenterX - gridCellWidth,
                topY,
                gridCellWidth,
                gridCellWidth
            )
            .setInteractive({ useHandCursor: true })
            .on("pointerdown", async () => {
                await Nakama.makeMove(0)
            });

        this.add
            .rectangle(gridCenterX, topY, gridCellWidth, gridCellWidth)
            .setInteractive({ useHandCursor: true })
            .on("pointerdown", () => {
                Nakama.makeMove(1)
            });

        // ...
    }
}

In the Main Menu scene, you authenticate the user in Nakama (discussed below) and display a “Welcome to XOXO” banner and button that, on click, takes the user to the In Game scene.

The Matchmaking scene simply displays a waiting spinner while Nakama finds an opponent for the user.

For the In Game scene, you are creating the interactive board of nine individual squares for players to enter their X’s and O’s. You also define the gameplay functions to set and update players turns, and end the game. The nakamaListener defined here communicates these actions via websocket to the Nakama server.

Connecting to Nakama #

  1. Next connect your client to Nakama server and configure it for device authentication:

nakama.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Client } from "@heroiclabs/nakama-js";
import { v4 as uuidv4 } from "uuid";

class Nakama {
    constructor() {
        this.client
        this.session
        this.socket
        this.matchID
    }

    async authenticate() {
        const useSSL = false;
        this.client = new Client("defaultkey", "localhost", "7350", useSSL);

        let deviceId = localStorage.getItem("deviceId");
        if (!deviceId) {
            deviceId = uuidv4();
            localStorage.setItem("deviceId", deviceId);
        }

        this.session = await this.client.authenticateDevice(deviceId, true);
        localStorage.setItem("user_id", this.session.user_id);

        const trace = false;
        this.socket = this.client.createSocket(this.useSSL, trace);
        await this.socket.connect(this.session);

    }

    // ...
When testing the game locally with two browser windows, you should set deviceId to uuidv4() rather than get it from localStorage, otherwise you will authenticate as the same user twice which will cause the first session to disconnect and make the game unplayable.
  1. Here you’ll also configure the multiplayer functionality. Learn more about this in the authoritative multiplayer and matchmaker documentation.

nakama.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Client } from "@heroiclabs/nakama-js";
import { v4 as uuidv4 } from "uuid";

class Nakama {
    constructor() {
        this.client
        this.session
        this.socket
        this.matchID
    }

    // ...

    async findMatch() {
        const rpcid = "find_match";
        const matches = await this.client.rpc(this.session, rpcid, {});

        this.matchID = matches.payload.matchIds[0]
        await this.socket.joinMatch(this.matchID);
        console.log("Matched joined!")
    }

    async makeMove(index) {
        var data = { "position": index };
        await this.socket.sendMatchState(this.matchID, 4, data);
        console.log("Match data sent")
    }
}

export default new Nakama()

And that’s it, you’re ready to play!

Further reading #

Learn more about the topics and features in this tutorial with the following: