Table of Contents
In the previous part we created a JavaScript class for the computer player. In this final part, we will build a simple user interface for the tic-tac-toe board. We will use classes and methods created in previous parts to simulate a game with a certain search depth and a starting player.
This is the second part of a 3 parts series. Below you can find the list of other parts:
- Part 1: Building the Tic-Tac-Toe Board
- Part 2: AI Player with Minimax Algorithm
- Part 3: Building the User Interface
The HTML Markup
Our HTML markup will be quite simple, just a div with an id of board. This div will be populated with cells in our JavaScript code. In addition to that, we will add a couple of drop-downs for the starting player and the depth. We will also add a new game button. And finally, let’s also create a style.css file in our root folder and include it using a link tag. Our index.html inside the root folder should finally look like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tic Tac Toe</title>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<div class="container">
<div id="board"></div>
<div class="field">
<label for="starting">Starting Player</label>
<select id="starting">
<option value="1">Human</option>
<option value="0">Computer</option>
</select>
</div>
<div class="field">
<label for="depth">Depth (Difficulty)</label>
<select id="depth">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option selected="selected" value="-1">Unlimited</option>
</select>
</div>
<button id="newGame">New Game</button>
</div>
<script type="module" src="script.js"></script>
</body>
</html>
Helper Functions
Let’s also create a new file in our root folder and call it helpers.js. In this file we will export some functions that will be useful when building our UI. First, we will add and remove classes to HTML elements during building the UI. So I borrowed some functions from Jake Trent’s blog to do exactly that. So let’s add these in helpers.js:
//Helpers (from http://jaketrent.com/post/addremove-classes-raw-javascript/)
export function hasClass(el, className) {
if (el.classList) return el.classList.contains(className);
else return !!el.className.match(new RegExp("(\s|^)" + className + "(\s|$)"));
}
export function addClass(el, className) {
if (el.classList) el.classList.add(className);
else if (!hasClass(el, className)) el.className += " " + className;
}
export function removeClass(el, className) {
if (el.classList) el.classList.remove(className);
else if (hasClass(el, className)) {
var reg = new RegExp("(\s|^)" + className + "(\s|$)");
el.className = el.className.replace(reg, " ");
}
}
In addition to that, we will define another helper function that takes the object returned from the isTerminal() method and uses the direction (row/column/diagonal) and the row/column number (1/2/3) or diagonal name (main/counter) to add a certain class to the board. This class will help us animate a line for the winning cells using CSS. For instance, if the winner won at the first row horizontally, then the class will be h-1. If the winner won at the main diagonal, the class will be d-main ans so on. We finally set a small timeout before we add another class (fullLine) which transitions the line’s width/height from 0 to 100% so that we can have an animation.
//Helper function that takes the object returned from isTerminal() and adds a
//class to the board that will handle drawing the winning line's animation
export function drawWinningLine(statusObject) {
if (!statusObject) return;
const { winner, direction, row, column, diagonal } = statusObject;
if (winner === "draw") return;
const board = document.getElementById("board");
addClass(board, `${direction.toLowerCase()}-${row || column || diagonal}`);
setTimeout(() => {
addClass(board, "fullLine");
}, 50);
}
The New Game Function
It’s time now to create a function that is responsible for creating a new game. The newGame function will receive two arguments; the maximum depth and the starting player (1 for human, 0 for computer). We will instantiate a new player with the given maximum depth and a new empty board. After that we will clear all classes on the board div from previous games and populate it with the cells divs. We will then store the populated cells in an array so we can loop on them and attach click events. We will also initialize some variables that we will use later and those are the starting player, whether the human is maximizing or minimizing and the current player turn. So let’s in our entry point (script.js) import our needed classes and functions and define our newGame function:
import Board from "./classes/board.js";
import Player from "./classes/player.js";
import { drawWinningLine, hasClass, addClass } from "./helpers.js";
//Starts a new game with a certain depth and a startingPlayer of 1 if human is going to start
function newGame(depth = -1, startingPlayer = 1) {
//Instantiating a new player and an empty board
const player = new Player(parseInt(depth));
const board = new Board(["", "", "", "", "", "", "", "", ""]);
//Clearing all #Board classes and populating cells HTML
const boardDIV = document.getElementById("board");
boardDIV.className = "";
boardDIV.innerHTML = `<div class="cells-wrap">
<button class="cell-0"></button>
<button class="cell-1"></button>
<button class="cell-2"></button>
<button class="cell-3"></button>
<button class="cell-4"></button>
<button class="cell-5"></button>
<button class="cell-6"></button>
<button class="cell-7"></button>
<button class="cell-8"></button>
</div>`;
//Storing HTML cells in an array
const htmlCells = [...boardDIV.querySelector(".cells-wrap").children];
//Initializing some variables for internal use
const starting = parseInt(startingPlayer),
maximizing = starting;
let playerTurn = starting;
}
In the next step, we will check if the computer will start. If so, instead to running the performance intense recursive getBestMove function on an empty board, we will choose a random cell as long as it’s a corner cell or the centre cell since an edge is not a great place to start with. We will assume the maximizing player is always X and the minimizing is O. Furthermore, we will add a class of x or o to the cell so we can use that in the CSS.
//If computer is going to start, choose a random cell as long as it is the center or a corner
if (!starting) {
const centerAndCorners = [0, 2, 4, 6, 8];
const firstChoice = centerAndCorners[Math.floor(Math.random() * centerAndCorners.length)];
const symbol = !maximizing ? "x" : "o";
board.insert(symbol, firstChoice);
addClass(htmlCells[firstChoice], symbol);
playerTurn = 1; //Switch turns
}
Finally, in our newGame function we will attach click events to each cell. While looping over our board state, we will attach a click event to the corresponding HTML cell that we stored inside htmlCells. Inside the event handler, we will break out of the function if the cell clicked is occupied or the game is over or it’s not the human’s turn. Otherwise we will insert the symbol into the cell and check if this move is a terminal win and draw the winning line accordingly. If it’s not a terminal move, we will switch turns and use getBestMove to simulate the computer’s turn and do the same terminal checks.
//Adding Click event listener for each cell
board.state.forEach((cell, index) => {
htmlCells[index].addEventListener(
"click",
() => {
//If cell is already occupied or the board is in a terminal state or it's not humans turn, return false
if (
hasClass(htmlCells[index], "x") ||
hasClass(htmlCells[index], "o") ||
board.isTerminal() ||
!playerTurn
)
return false;
const symbol = maximizing ? "x" : "o"; //Maximizing player is always 'x'
//Update the Board class instance as well as the Board UI
board.insert(symbol, index);
addClass(htmlCells[index], symbol);
//If it's a terminal move and it's not a draw, then human won
if (board.isTerminal()) {
drawWinningLine(board.isTerminal());
}
playerTurn = 0; //Switch turns
//Get computer's best move and update the UI
player.getBestMove(board, !maximizing, best => {
const symbol = !maximizing ? "x" : "o";
board.insert(symbol, parseInt(best));
addClass(htmlCells[best], symbol);
if (board.isTerminal()) {
drawWinningLine(board.isTerminal());
}
playerTurn = 1; //Switch turns
});
},
false
);
if (cell) addClass(htmlCells[index], cell);
});
Final Touches
We are now left with just initializing a new game when the page loads or when the user clicks the new game button. Notice that if the new game button is clicked, we will initialize the game with the options that the user chose:
document.addEventListener("DOMContentLoaded", () => {
//Start a new game when page loads with default values
const depth = -1;
const startingPlayer = 1;
newGame(depth, startingPlayer);
//Start a new game with chosen options when new game button is clicked
document.getElementById("newGame").addEventListener("click", () => {
const startingDIV = document.getElementById("starting");
const starting = startingDIV.options[startingDIV.selectedIndex].value;
const depthDIV = document.getElementById("depth");
const depth = depthDIV.options[depthDIV.selectedIndex].value;
newGame(depth, starting);
});
});
We are now done with the JavaScript part. We now need to style the board with a little bit of CSS. I used plain CSS here without any pre-processors:
* {
box-sizing: border-box;
}
body {
background-color: #0a0710;
}
.container {
max-width: 500px;
padding: 0 30px;
margin: 100px auto;
}
.field {
margin-bottom: 20px;
}
.field label {
color: #fff;
}
#board {
width: 100%;
padding-top: 100%;
position: relative;
margin-bottom: 30px;
}
#board .cells-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
#board [class^="cell-"] {
height: 33.3333333%;
width: 33.3333333%;
border: 2px solid #0a0710;
background: #130f1e;
position: relative;
cursor: pointer;
color: #fff;
font-size: calc(18px + 5vw);
font-family: fantasy;
}
#board [class^="cell-"].x,
#board [class^="cell-"].o {
cursor: not-allowed;
}
#board [class^="cell-"].x:after {
content: "x";
}
#board [class^="cell-"].o:after {
content: "o";
}
#board:after {
content: "";
position: absolute;
background-color: #c11dd4;
transition: 0.7s;
}
/* Horizontal Lines */
#board[class^="h-"]:after {
width: 0%;
height: 3px;
left: 0;
transform: width translateY(-50%);
}
#board.fullLine[class^="h-"]:after {
width: 100%;
}
#board.h-1:after {
top: 16.6666666665%;
}
#board.h-2:after {
top: 50%;
}
#board.h-3:after {
top: 83.33333333%;
}
/* Vertical Lines */
#board[class^="v-"]:after {
width: 3px;
height: 0%;
top: 0;
transform: height translateX(-50%);
}
#board.fullLine[class^="v-"]:after {
height: 100%;
}
#board.v-1:after {
left: 16.6666666665%;
}
#board.v-2:after {
left: 50%;
}
#board.v-3:after {
left: 83.33333333%;
}
/* Diagonal Lines */
#board[class^="d-main"]:after {
width: 3px;
height: 0%;
left: 0;
top: 0;
transform: rotateZ(-45deg);
transform-origin: 50% 0;
transition: height 0.7s;
}
#board.fullLine[class^="d-main"]:after {
height: 140%;
}
#board[class^="d-counter"]:after {
height: 0%;
width: 3px;
right: 0;
top: 0;
transform: rotateZ(45deg);
transform-origin: 50% 0;
transition: height 0.7s;
}
#board.fullLine[class^="d-counter"]:after {
height: 140%;
}
And our final output should look like this: