Tic-Tac-Toe is a great project for beginners who want to learn how to build games. It’s simple to understand but gives you the chance to learn about game state, player turns, winning logic, and user input.
In this tutorial, you’ll learn how to build tic-tac-toe using Phaser.js, a fast, fun, and open source framework for making 2D games in the browser.
If you’re new to Phaser.js, don’t worry. We’ll walk through everything step-by-step. By the end, you’ll have a working game that you can play, share, or build upon.
You can play the game here to get a feel of what you are going to build.
Table of Contents
What is Phaser.js?
Phaser.js is a free and open-source JavaScript game framework. It helps developers create HTML5 games that work across web browsers. Phaser handles things like rendering graphics, detecting input, and running the game loop.
You can use Phaser to make simple games like Pong and Tic-Tac-Toe or advanced platformers and role playing games. It supports both Canvas and WebGL rendering, so your games will run smoothly on most devices.
Project Setup
Create a folder for your project and add two files: index.html
and game.js
. The HTML file loads Phaser and the JavaScript file contains the game logic. Here is the repository with the finished code.
Here’s what the index.html
file should look like:
<span class="hljs-meta"><!DOCTYPE <span class="hljs-meta-keyword">html</span>></span>
<span class="hljs-tag"><<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">head</span>></span>
<span class="hljs-tag"><<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span> /></span>
<span class="hljs-tag"><<span class="hljs-name">title</span>></span>Tic Tac Toe — Phaser 3<span class="hljs-tag"></<span class="hljs-name">title</span>></span>
<span class="hljs-tag"><<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width,initial-scale=1"</span> /></span>
<span class="hljs-tag"><<span class="hljs-name">style</span>></span><span class="css">
<span class="hljs-selector-tag">html</span>, <span class="hljs-selector-tag">body</span> { <span class="hljs-attribute">height</span>: <span class="hljs-number">100%</span>; <span class="hljs-attribute">margin</span>: <span class="hljs-number">0</span>; <span class="hljs-attribute">background</span>: <span class="hljs-number">#0f172a</span>; <span class="hljs-attribute">display</span>: grid; <span class="hljs-attribute">place-items</span>: center; }
<span class="hljs-selector-id">#game</span> { <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">10px</span> <span class="hljs-number">30px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>,<span class="hljs-number">0</span>,<span class="hljs-number">0</span>,.<span class="hljs-number">35</span>); <span class="hljs-attribute">border-radius</span>: <span class="hljs-number">12px</span>; <span class="hljs-attribute">overflow</span>: hidden; }
<span class="hljs-selector-class">.hint</span> { <span class="hljs-attribute">color</span>: <span class="hljs-number">#e2e8f0</span>; <span class="hljs-attribute">margin-top</span>: <span class="hljs-number">10px</span>; <span class="hljs-attribute">font-size</span>: <span class="hljs-number">14px</span>; <span class="hljs-attribute">text-align</span>: center; <span class="hljs-attribute">opacity</span>: .<span class="hljs-number">85</span>; }
</span><span class="hljs-tag"></<span class="hljs-name">style</span>></span>
<span class="hljs-tag"><<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js"</span>></span><span class="hljs-tag"></<span class="hljs-name">script</span>></span>
<span class="hljs-tag"></<span class="hljs-name">head</span>></span>
<span class="hljs-tag"><<span class="hljs-name">body</span>></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"game"</span>></span><span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hint"</span>></span>Click a cell to play. Tap “Restart” to start over.<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"./game.js"</span>></span><span class="hljs-tag"></<span class="hljs-name">script</span>></span>
<span class="hljs-tag"></<span class="hljs-name">body</span>></span>
<span class="hljs-tag"></<span class="hljs-name">html</span>></span>
This sets up a simple HTML page, loads Phaser from a CDN, and points to your game.js
file. The #game
container is where Phaser will insert the game canvas.
How to Set Up the Game Configuration
Phaser games are built from a configuration object that defines things like width, height, background color, and which functions to call for loading, creating, and updating the game.
(<span class="hljs-function">() =></span> {
<span class="hljs-keyword">const</span> GRID = <span class="hljs-number">3</span>;
<span class="hljs-keyword">const</span> CELL = <span class="hljs-number">120</span>;
<span class="hljs-keyword">const</span> BOARD = GRID * CELL;
<span class="hljs-keyword">const</span> HUD = <span class="hljs-number">72</span>;
<span class="hljs-keyword">const</span> WIDTH = BOARD;
<span class="hljs-keyword">const</span> HEIGHT = BOARD + HUD;
<span class="hljs-keyword">let</span> scene;
<span class="hljs-keyword">let</span> board;
<span class="hljs-keyword">let</span> currentPlayer;
<span class="hljs-keyword">let</span> gameOver;
<span class="hljs-keyword">let</span> gridGfx;
<span class="hljs-keyword">let</span> overlayGfx;
<span class="hljs-keyword">let</span> marks = [];
<span class="hljs-keyword">let</span> statusText;
<span class="hljs-keyword">let</span> restartText;
<span class="hljs-keyword">const</span> config = {
<span class="hljs-attr">type</span>: Phaser.AUTO,
<span class="hljs-attr">parent</span>: <span class="hljs-string">"game"</span>,
<span class="hljs-attr">width</span>: WIDTH,
<span class="hljs-attr">height</span>: HEIGHT,
<span class="hljs-attr">backgroundColor</span>: <span class="hljs-string">"#ffffff"</span>,
<span class="hljs-attr">scale</span>: { <span class="hljs-attr">mode</span>: Phaser.Scale.FIT, <span class="hljs-attr">autoCenter</span>: Phaser.Scale.CENTER_BOTH },
<span class="hljs-attr">scene</span>: { preload, create, update }
};
<span class="hljs-keyword">new</span> Phaser.Game(config);
We start by defining constants for the grid size and cell size. The config
object tells Phaser to create a game with these dimensions and use the preload
, create
, and update
functions we will define.
How to Preload Assets
Since we are drawing everything with Phaser’s graphics and text tools, we do not need to load any external images or sounds.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">preload</span>(<span class="hljs-params"></span>) </span>{
<span class="hljs-comment">// No assets to load</span>
}
This is a placeholder that lets Phaser call preload
before the game starts.
How to Create the Game Scene
The create
function runs once at the start of the scene. Here we draw the grid, set up the initial state, and add UI elements.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">create</span>(<span class="hljs-params"></span>) </span>{
scene = <span class="hljs-built_in">this</span>;
gridGfx = scene.add.graphics({ <span class="hljs-attr">lineStyle</span>: { <span class="hljs-attr">width</span>: <span class="hljs-number">4</span>, <span class="hljs-attr">color</span>: <span class="hljs-number">0x000000</span> } });
overlayGfx = scene.add.graphics();
drawGrid();
initGame();
statusText = scene.add.text(WIDTH / <span class="hljs-number">2</span>, BOARD + <span class="hljs-number">12</span>, <span class="hljs-string">"Player X's turn"</span>, {
<span class="hljs-attr">fontSize</span>: <span class="hljs-string">"20px"</span>,
<span class="hljs-attr">color</span>: <span class="hljs-string">"#111"</span>,
<span class="hljs-attr">fontFamily</span>: <span class="hljs-string">"Arial, Helvetica, sans-serif"</span>
}).setOrigin(<span class="hljs-number">0.5</span>, <span class="hljs-number">0</span>);
restartText = scene.add.text(WIDTH / <span class="hljs-number">2</span>, BOARD + <span class="hljs-number">38</span>, <span class="hljs-string">"Restart"</span>, {
<span class="hljs-attr">fontSize</span>: <span class="hljs-string">"18px"</span>,
<span class="hljs-attr">color</span>: <span class="hljs-string">"#2563eb"</span>,
<span class="hljs-attr">fontFamily</span>: <span class="hljs-string">"Arial, Helvetica, sans-serif"</span>
}).setOrigin(<span class="hljs-number">0.5</span>, <span class="hljs-number">0</span>).setInteractive({ <span class="hljs-attr">useHandCursor</span>: <span class="hljs-literal">true</span> });
restartText.on(<span class="hljs-string">"pointerup"</span>, hardReset);
scene.input.on(<span class="hljs-string">"pointerdown"</span>, onPointerDown, scene);
}
We created two Graphics
objects: one for the static grid and another for the win line. Then we called drawGrid()
and initGame()
to set up the game board. The status text and restart button are placed below the grid. We also listened for clicks on the board with pointerdown
.
How to Draw the Grid
The grid is made up of two vertical and two horizontal lines.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">drawGrid</span>(<span class="hljs-params"></span>) </span>{
gridGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(CELL, <span class="hljs-number">0</span>, CELL, BOARD));
gridGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(CELL * <span class="hljs-number">2</span>, <span class="hljs-number">0</span>, CELL * <span class="hljs-number">2</span>, BOARD));
gridGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(<span class="hljs-number">0</span>, CELL, BOARD, CELL));
gridGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(<span class="hljs-number">0</span>, CELL * <span class="hljs-number">2</span>, BOARD, CELL * <span class="hljs-number">2</span>));
}
We use Phaser.Geom.Line
to define the start and end points for each line and then draw them with strokeLineShape
.
How to Initialize and Reset the Game
The initGame
function sets up a new game, and hardReset
is called when the restart button is clicked.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">initGame</span>(<span class="hljs-params"></span>) </span>{
board = <span class="hljs-built_in">Array</span>.from({ <span class="hljs-attr">length</span>: GRID }, <span class="hljs-function">() =></span> <span class="hljs-built_in">Array</span>(GRID).fill(<span class="hljs-string">""</span>));
currentPlayer = <span class="hljs-string">"X"</span>;
gameOver = <span class="hljs-literal">false</span>;
overlayGfx.clear();
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> t <span class="hljs-keyword">of</span> marks) t.destroy();
marks = [];
setStatus(<span class="hljs-string">"Player X's turn"</span>);
}
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">hardReset</span>(<span class="hljs-params"></span>) </span>{
initGame();
}
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setStatus</span>(<span class="hljs-params">msg</span>) </span>{
statusText && statusText.setText(msg);
}
The board is represented by a 2D array filled with empty strings. The current player starts as X, and the marks
array keeps track of text objects so we can clear them on reset.
How to Handle Player Input
When the player clicks a cell, we determine its row and column and check if the move is valid.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">onPointerDown</span>(<span class="hljs-params">pointer</span>) </span>{
<span class="hljs-keyword">if</span> (gameOver) <span class="hljs-keyword">return</span>;
<span class="hljs-keyword">if</span> (pointer.y > BOARD) <span class="hljs-keyword">return</span>;
<span class="hljs-keyword">const</span> col = <span class="hljs-built_in">Math</span>.floor(pointer.x / CELL);
<span class="hljs-keyword">const</span> row = <span class="hljs-built_in">Math</span>.floor(pointer.y / CELL);
<span class="hljs-keyword">if</span> (!inBounds(row, col)) <span class="hljs-keyword">return</span>;
<span class="hljs-keyword">if</span> (board[row][col] !== <span class="hljs-string">""</span>) <span class="hljs-keyword">return</span>;
placeMark(row, col, currentPlayer);
<span class="hljs-keyword">const</span> win = checkWin(board);
<span class="hljs-keyword">if</span> (win) {
gameOver = <span class="hljs-literal">true</span>;
drawWinLine(win);
setStatus(<span class="hljs-string">`Player <span class="hljs-subst">${currentPlayer}</span> wins!`</span>);
<span class="hljs-keyword">return</span>;
}
<span class="hljs-keyword">if</span> (isFull(board)) {
gameOver = <span class="hljs-literal">true</span>;
setStatus(<span class="hljs-string">"Draw! No more moves."</span>);
<span class="hljs-keyword">return</span>;
}
currentPlayer = currentPlayer === <span class="hljs-string">"X"</span> ? <span class="hljs-string">"O"</span> : <span class="hljs-string">"X"</span>;
setStatus(<span class="hljs-string">`Player <span class="hljs-subst">${currentPlayer}</span>'s turn`</span>);
}
This ensures that we only act if the game is not over, the click is inside the board, and the chosen cell is empty. After placing a mark, we check for a win or draw before switching turns.
How to Place Marks on the Board
We display an X or O at the center of the clicked cell.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">inBounds</span>(<span class="hljs-params">r, c</span>) </span>{
<span class="hljs-keyword">return</span> r >= <span class="hljs-number">0</span> && r < GRID && c >= <span class="hljs-number">0</span> && c < GRID;
}
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">placeMark</span>(<span class="hljs-params">row, col, player</span>) </span>{
board[row][col] = player;
<span class="hljs-keyword">const</span> cx = col * CELL + CELL / <span class="hljs-number">2</span>;
<span class="hljs-keyword">const</span> cy = row * CELL + CELL / <span class="hljs-number">2</span>;
<span class="hljs-keyword">const</span> t = scene.add.text(cx, cy, player, {
<span class="hljs-attr">fontSize</span>: <span class="hljs-built_in">Math</span>.floor(CELL * <span class="hljs-number">0.66</span>) + <span class="hljs-string">"px"</span>,
<span class="hljs-attr">color</span>: <span class="hljs-string">"#111111"</span>,
<span class="hljs-attr">fontFamily</span>: <span class="hljs-string">"Arial, Helvetica, sans-serif"</span>
}).setOrigin(<span class="hljs-number">0.5</span>);
marks.push(t);
}
The coordinates are calculated so the text is centered in the cell. We store the text object in the marks
array so it can be removed when resetting.
How to Check for a Winner
We check rows, columns, and diagonals to see if the current player has three in a row.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">checkWin</span>(<span class="hljs-params">b</span>) </span>{
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> r = <span class="hljs-number">0</span>; r < GRID; r++) {
<span class="hljs-keyword">if</span> (b[r][<span class="hljs-number">0</span>] && b[r][<span class="hljs-number">0</span>] === b[r][<span class="hljs-number">1</span>] && b[r][<span class="hljs-number">1</span>] === b[r][<span class="hljs-number">2</span>]) {
<span class="hljs-keyword">return</span> { <span class="hljs-attr">kind</span>: <span class="hljs-string">"row"</span>, <span class="hljs-attr">index</span>: r };
}
}
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> c = <span class="hljs-number">0</span>; c < GRID; c++) {
<span class="hljs-keyword">if</span> (b[<span class="hljs-number">0</span>][c] && b[<span class="hljs-number">0</span>][c] === b[<span class="hljs-number">1</span>][c] && b[<span class="hljs-number">1</span>][c] === b[<span class="hljs-number">2</span>][c]) {
<span class="hljs-keyword">return</span> { <span class="hljs-attr">kind</span>: <span class="hljs-string">"col"</span>, <span class="hljs-attr">index</span>: c };
}
}
<span class="hljs-keyword">if</span> (b[<span class="hljs-number">0</span>][<span class="hljs-number">0</span>] && b[<span class="hljs-number">0</span>][<span class="hljs-number">0</span>] === b[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>] && b[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>] === b[<span class="hljs-number">2</span>][<span class="hljs-number">2</span>]) {
<span class="hljs-keyword">return</span> { <span class="hljs-attr">kind</span>: <span class="hljs-string">"diag"</span> };
}
<span class="hljs-keyword">if</span> (b[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>] && b[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>] === b[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>] && b[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>] === b[<span class="hljs-number">2</span>][<span class="hljs-number">0</span>]) {
<span class="hljs-keyword">return</span> { <span class="hljs-attr">kind</span>: <span class="hljs-string">"anti"</span> };
}
<span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
}
If a win is found, we return an object describing the winning line so it can be drawn.
How to Detect a Draw
If every cell is filled and there is no winner, the game ends in a draw.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">isFull</span>(<span class="hljs-params">b</span>) </span>{
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> r = <span class="hljs-number">0</span>; r < GRID; r++) {
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> c = <span class="hljs-number">0</span>; c < GRID; c++) {
<span class="hljs-keyword">if</span> (b[r][c] === <span class="hljs-string">""</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
}
}
<span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
}
This loops over every cell and returns false if any are empty.
How to Draw the Winning Line
A red line is drawn over the winning cells.
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">drawWinLine</span>(<span class="hljs-params">res</span>) </span>{
overlayGfx.clear();
overlayGfx.lineStyle(<span class="hljs-number">6</span>, <span class="hljs-number">0xef4444</span>, <span class="hljs-number">1</span>);
<span class="hljs-keyword">const</span> pad = <span class="hljs-number">14</span>;
<span class="hljs-keyword">const</span> half = CELL / <span class="hljs-number">2</span>;
<span class="hljs-keyword">if</span> (res.kind === <span class="hljs-string">"row"</span>) {
<span class="hljs-keyword">const</span> y = res.index * CELL + half;
overlayGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(pad, y, BOARD - pad, y));
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (res.kind === <span class="hljs-string">"col"</span>) {
<span class="hljs-keyword">const</span> x = res.index * CELL + half;
overlayGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(x, pad, x, BOARD - pad));
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (res.kind === <span class="hljs-string">"diag"</span>) {
overlayGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(pad, pad, BOARD - pad, BOARD - pad));
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (res.kind === <span class="hljs-string">"anti"</span>) {
overlayGfx.strokeLineShape(<span class="hljs-keyword">new</span> Phaser.Geom.Line(BOARD - pad, pad, pad, BOARD - pad));
}
}
})();
The coordinates are calculated based on the type of win to ensure the line passes through the correct cells.
Great. Now open index.html
and you can start playing the game!
Final Thoughts
You have now built a complete Tic Tac Toe game in Phaser.js. This includes a 3×3 grid, alternating turns, win detection with a highlight line, draw detection, and a restart button. The code uses core game development concepts like input handling, game state management, and rendering, which you can use in larger projects.
If you enjoy online games, check out GameBoost, the ultimate marketplace for gamers. You can find Fortnite accounts with exclusive skins, along with options for other popular games like Grow a Garden, Clash of Clans, and more.
Source: freeCodeCamp Programming Tutorials: Python, JavaScript, Git & MoreÂ