x
Tic-Tac-Toe with Javascript ES2015

Part 4: Building the User Interface

This is a part of the series: Tic-Tac-Toe with Javascript ES2015

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.

View a demo or visit the project’s github page.
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 inside our JavaScript code. In addition to that, we will add a couple of drop-downs for the starting player and the depth and a new game button. Our index.html inside the root folder should finally look like this:

<!DOCTYPE html>
<html>
<head>
	<title>Tic Tac Toe with Javascript</title>
	<link rel="stylesheet" type="text/css" href="dist/style.css">
</head>
<body>
	
	<div id="board"></div>
	<br><br>
	<select id="starting">
		<option value="1">Human</option>
		<option value="0">Computer</option>
	</select>
	<select id="depth">
		<option value="1">1</option>
		<option value="2">2</option>
		<option value="3">3</option>
		<option value="4">4</option>
		<option value="-1">Unlimited</option>
	</select>

	<button id="newgame">New Game</button>

	<script type="text/javascript" src="dist/bundle.js"></script>
</body>
</html>

 

Preparing the webpack Entry Point

As mentioned in Part 1, webpack has a JS file called the entry point. This file is where we import all our different JS classes and styles so that webpack can compile the final code. In our case we set the entry point to be index.js file in the src directory. We will start by importing all the necessary files and we will also define some helper functions we will use in adding and removing classes.

import Board from './classes/Board';
import Player from './classes/Player';
import './style.scss';

//Helpers (from http://jaketrent.com/post/addremove-classes-raw-javascript/)
function hasClass(el, className) {
  if (el.classList)
    return el.classList.contains(className);
  else
    return !!el.className.match(new RegExp('(\\s|^)' + className + '(\\s|$)'));
}
function addClass(el, className) {
  if (el.classList)
    el.classList.add(className);
  else if (!hasClass(el, className)) el.className += " " + className;
}
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 isTerminal() method and uses the direction and row/column number 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 H1. We finally set a small timeout before we add another class (full) which transitions the lines width 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
function drawWinningLine({ direction, row }) {
    let board = document.getElementById("board");
    board.className = `${direction}${row}`;
    setTimeout(() => { board.className += ' full'; }, 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 take two arguments; the depth and the starting player (1 for human, 0 for computer). We will instantiate a new player with the given 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.

//Starts a new game with a certain depth and a starting_player of 1 if human is going to start
function newGame(depth = -1, starting_player = 1) {
	//Instantiating a new player and an empty board
	let p = new Player(parseInt(depth));
	let b = new Board(['','','','','','','','','']);

	//Clearing all #Board classes and populating cells HTML
	let board = document.getElementById("board");
	board.className = '';
	board.innerHTML = '<div class="cell-0"></div><div class="cell-1"></div><div class="cell-2"></div><div class="cell-3"></div><div class="cell-4"></div><div class="cell-5"></div><div class="cell-6"></div><div class="cell-7"></div><div class="cell-8"></div>';

	//Storing HTML cells in an array
	let html_cells = [...board.children];

	//Initializing some variables for internal use
	let starting = parseInt(starting_player),
		maximizing = starting,
		player_turn = starting;

}

In the next step, we will check if the computer will start. If so, instead to running the performance costly recursive getBestMove function on an empty board, we will choose a random cell as long as it’s a corner or the centre since an edge is not a great place to start. 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) {
    let center_and_corners = [0,2,4,6,8];
    let first_choice = center_and_corners[Math.floor(Math.random()*center_and_corners.length)];
    let symbol = !maximizing ? 'x' : 'o';
    b.insert(symbol, first_choice);
    addClass(html_cells[first_choice], symbol);
    player_turn = 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 html_cells. 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 turn and use getBestMove to play the computer’s turn and do the same terminal checks.

//Adding Click event listener for each cell
b.state.forEach((cell, index) => {
    html_cells[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(html_cells[index], 'x') || hasClass(html_cells[index], 'o') || b.isTerminal() || !player_turn) return false;

        let symbol = maximizing ? 'x' : 'o'; //Maximizing player is always 'x'

        //Update the Board class instance as well as the Board UI
        b.insert(symbol, index);
        addClass(html_cells[index], symbol);

        //If it's a terminal move and it's not a draw, then human won
        if(b.isTerminal()) {
            let { winner } = b.isTerminal();
            drawWinningLine(b.isTerminal());
        }
        player_turn = 0; //Switch turns

        //Get computer's best move and update the UI
        p.getBestMove(b, !maximizing, best => {
            let symbol = !maximizing ? 'x' : 'o';
            b.insert(symbol, best);
            addClass(html_cells[best], symbol);
            if(b.isTerminal()) {
                let { winner } = b.isTerminal();
                drawWinningLine(b.isTerminal());
            }
            player_turn = 1; //Switch turns
        });
    }, false);
    if(cell) addClass(html_cells[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 arguments that the user has chosen:

document.addEventListener("DOMContentLoaded", event => { 

	//Start a new game when page loads with default values
	let depth = -1;
	let starting_player = 1;
	newGame(depth, starting_player);

	document.getElementById("newgame").addEventListener('click', () => {

		var starting = document.getElementById("starting");
		var starting = starting.options[starting.selectedIndex].value;

		var depth = document.getElementById("depth");
		var depth = depth.options[depth.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 sass here which we configured webpack to compile.

#board {
	width: 460px;
	height: 460px;
	position: relative;
	box-sizing: border-box;
	* {
		box-sizing: border-box;
	}
	&:after {
		content: '';
		position: absolute;
	    background-color: #52d29d;
	    transition: 0.7s;
	}
	&[class^="H"] {
		&:after {
			width: 0%;
			height: 3px;
			left: 0;
			transform: translateY(-50%);
		}
		&.full:after {
			width: 100%;
		}
	}
	&.H1:after {
		top: 16.6666666665%;
	}
	&.H2:after {
		top: 50%;
	}
	&.H3:after {
		top: 83.33333333%;
	}
	&[class^="V"] {
		&:after {
			width: 3px;
			height: 0%;
			top: 0;
			transform: translateX(-50%);
		}
		&.full:after {
			height: 100%;
		}
	}
	&.V1:after {
		left: 16.6666666665%;
	}
	&.V2:after {
		left: 50%;
	}
	&.V3:after {
		left: 83.33333333%;
	}
	&.D1 {
		&:after {
			height: 0%;
			width: 3px;
			left: 0;
			top: 0;
			transform: rotateZ(-45deg);
			transform-origin: 50% 0;
			transition: height 0.7s;
		}
		&.full:after {
			height: 140%;
		}
	}
	&.D2 {
		&:after {
			height: 0%;
			width: 3px;
			right: 0;
			top: 0;
			transform: rotateZ(45deg);
			transform-origin: 50% 0;
			transition: height 0.7s;
		}
		&.full:after {
			height: 140%;
		}
	}
	[class^="cell-"] {
		height: 33.3333333%;
		width: 33.3333333%;
	    float: left;
        border: 1px solid #000;
        position: relative;
        cursor: pointer;
        &.x, &.o {
			cursor: not-allowed;
			&:after {
				position: absolute;
				width: calc(100% - 60px);
				height: calc(100% - 60px);
				margin: auto;
				top: 0;
				bottom: 0;
				left: 0;
				right: 0;
				font-size: 65px;
				text-align: center;
				font-family: cursive;
				line-height: 70px;
			}
        }
        &.x:after {
			content: 'x';
        }
        &.o:after {
			content: 'o';
        }
	}
	.cell-0, .cell-1, .cell-2 {
		border-top: none;
	}
	.cell-2, .cell-5, .cell-8 {
		border-right: none;
	}
	.cell-0, .cell-3, .cell-6 {
		border-left: none;
	}
	.cell-6, .cell-7, .cell-8 {
		border-bottom: none;
	}
}

The final output should look like this:

 

Ali Alaa

Front-end developer from Egypt. Telecommunications Engineering graduate. Been working in web development since graduation.