Building a Tic-Tac-Toe application using html, css, and vanilla javascript
In this article, let's build a Tic-Tac-Toe application using HTML, CSS, and Vanilla JavaScript. We can alternatively use React as well but in the interview, we certainly don't know if we are allowed to use any libraries like React. If I ask a question, I would want it to be in vanilla javascript/typescript. Frameworks/libraries can be adapted easily once you have a handle on the JS/TS fundamentals.
(I have taken more than 200 interviews over my 10+ years of career. I have dealt with many candidates and in this article, I have tried to note down the most important things I would expect from a candidate as an interviewer)
Tip - Since it is kind of a system design problem, it is generally recommended to talk through the thought process and mention the assumptions.
If I were the candidate, I would generally ask questions like (but not limited to):
(Answers/Replies from the interviewer are mentioned inline)
Do we need responsiveness support? - No
Do we need to consider accessibility guidelines for this interview? - No
Is it safe to assume the javascript support will be enabled in the browsers? - Yes
Should the second player be "Automatic" / "Manual"? - Manual
Does it always need to be 3*3 or should it extend for other 5*5 kinda layouts? - Always 3*3
Should we get the input of Player1/2's names from the user (or) can we just use "Player1" and "Player2" notations? - Player 1 and Player 2 assumption is fine.
Can we hard-code "X" and "O" for players 1 and 2 respectively? (or) should they be got from the user input? - You can hard-code.
What happens in case of a tie?
- Should we reset the game? - Yes.
What are the details to be displayed? - Players names (Player1, Player2) and their wins count.
Should we have a counter on wins and losses? - Yes
Do we need to handle the API calls to persist the data? (or) local storage is fine? - Just in-memory is fine.
Do we need to write the code for HTML, CSS, and JS? - Yes. The solution has to be as complete as possible.
Do we need to worry about logging, metrics, and other analytics? - No
...
(Interviewer - Also, have the "X" and "O" alternate turns to start the game. i.e if X starts the first game, O has to start the next game, and so on.)
These kinds of questions would give a clear picture to the interviewer about your thought process if you are going in the right direction.
Tip - Once the assumptions and questions are clarified in the open, it is generally good to take some time to gather your thoughts in your mind as to how you are going to approach this problem. You can even let the interviewer know about this and he/she would be happy to let you think for a while.
After thinking, I would generally go about explaining the approach at a high level. In this case, I am thinking to have everything (html, styling) done with javascript as it is easier to have as a single module and we can init like below:
new TicTacToe(parentContainer);
Taking a parentContainer
as an argument would make it reusable in multiple places or even multiple times on the same page under different parent elements. I would also go ahead and accept another argument for the scoreboard, something like:
new TicTacToe(parentContainer, scoreBoardInstance);
What this essentially does is decouple the game feature from the scoreboard. Game and scoreboard responsibilities are entirely different and need not be combined into one. We can even sometimes run just the game without the scoreboard, in order to do that, we just won't pass scoreBoardInstance
. This makes it nicely decoupled. That also means we would need another class ScoreBoard
So, the call would be something like this:
const scoreBoard = new ScoreBoard();
new TicTacToe(parentContainer, scoreBoard);
We will come to the ScoreBoard implementation once we are done with the actual game.
Assuming the HTML to be something like:
<div id="gameContainer"></div>
<div id="score"></div>
class TicTacToe {
DEFAULTS = {
size: 3,
players: [
{
name: "Player 1",
symbol: "X",
color: "BLACK"
},
{
name: "Player 2",
symbol: "O",
color: "RED"
}
]
};
constructor(container, scoreBoard) {
if (!(container instanceof HTMLElement)) {
throw new Error("Container needs to be a HTML element");
}
this.turnSymbol = "X";
this.gameStarters = "X";
this.currentTile = [];
this.board = [];
this.totalTiles = this.DEFAULTS.size * this.DEFAULTS.size;
this.container = container;
this.scoreBoard = scoreBoard || null;
this.scoreBoard.init(this.DEFAULTS.players.map((player) => player.name));
this.setup();
}
flipTurn() {
this.turnSymbol = this.turnSymbol === "X" ? "O" : "X";
}
getCurrentPlayer() {
const { players } = this.DEFAULTS;
return players.filter((player) => player.symbol === this.turnSymbol)[0];
}
getFillText() {
return this.getCurrentPlayer().symbol;
}
assignToGameBoard(x, y, text) {
this.board[x][y] = text;
}
fillTile(x, y, text = "") {
const tile = document.querySelector(`[data-position="${x}-${y}"]`);
tile.innerHTML = text;
this.assignToGameBoard(x, y, text);
}
getTile(i, j) {
const tile = document.createElement("div");
tile.classList.add("tile");
tile.setAttribute("data-position", `${i}-${j}`);
tile.style.display = "block";
tile.style.border = "1px solid blue";
return tile;
}
renderTiles() {
const container = document.createElement("div");
container.classList.add("tic-tac-toe");
container.style.display = "grid";
container.style.height = "inherit";
container.style["grid-template-columns"] = "repeat(3, 1fr)";
container.style["grid-template-rows"] = "repeat(3, 1fr)";
const fragment = document.createDocumentFragment();
this.callbackWhileRendering((i, j) => {
fragment.appendChild(this.getTile(i, j));
});
container.appendChild(fragment);
this.container.appendChild(container);
}
callbackWhileRendering(callback) {
for (let i = 0; i < this.DEFAULTS.size; ++i) {
this.board[i] = [];
for (let j = 0; j < this.DEFAULTS.size; ++j) {
callback.call(null, i, j);
}
}
}
setup() {
this.renderTiles();
this.handleEvents();
this.scoreBoard.log("start");
}
checkProgress() {
const [x, y] = this.currentTile;
const { size } = this.DEFAULTS;
const text = this.getFillText();
let flag = true;
// Check horizontal line
for (let i = 0; i < size; ++i) {
if (this.board[x][i] !== text) {
flag = false;
}
}
if (flag) {
// We have a winner in the horizontal line
this.announceWinner();
return false;
}
flag = true;
// Check vertical line
for (let i = 0; i < size; ++i) {
if (this.board[i][y] !== text) {
flag = false;
}
}
if (flag) {
// We have a winner in the vertical line
this.announceWinner();
return false;
}
flag = true;
// Check right diagonal line
for (let i = 0; i < size; ++i) {
if (this.board[i][i] !== text) {
flag = false;
}
}
if (flag) {
// We have a winner in the horizontal line
this.announceWinner();
return false;
}
// Check left diagonal line
for (let i = 0, j = 2; i < size; ++i, --j) {
if (this.board[j][i] !== text) {
flag = false;
}
}
if (flag) {
// We have a winner in the horizontal line
this.announceWinner();
return false;
}
return this.totalTiles > 0;
}
resetBoard() {
this.board = [];
this.currentTile = [];
this.gameStarters = this.gameStarters === "X" ? "O" : "X";
this.turnSymbol = this.gameStarters;
this.totalTiles = this.DEFAULTS.size * this.DEFAULTS.size;
this.callbackWhileRendering((i, j) => {
this.fillTile(i, j, "");
});
}
announceWinner() {
this.scoreBoard.log("winner", this.getCurrentPlayer().name);
this.resetBoard();
}
tieGame() {
this.scoreBoard.log("tie");
this.resetBoard();
}
handleTileClick(x, y) {
// We write 'X' (or) 'O' depending on the turn.
this.currentTile = [x, y];
this.fillTile(x, y, this.getFillText());
--this.totalTiles;
const hasNext = this.checkProgress();
if (hasNext) {
this.flipTurn();
} else {
if (this.totalTiles === 0) {
this.tieGame();
}
}
}
handleEvents() {
this.container.addEventListener("click", (event) => {
if (event.target.closest(".tile")) {
if (event.target.innerHTML !== "") {
// User is clicking on the same tile again
return;
}
// If the user clicks on any tile, we get a callback
// and we get the position from the data props.
const [x, y] = event.target.getAttribute("data-position").split("-");
this.handleTileClick(x, y);
}
});
}
}
The TicTacToe
class can be invoked by:
new TicTacToe(document.getElementById("gameContainer"));
It currently does the following:
renders the "X", and "O" according to players' turns.
Resets the game if there is a winner/tie.
Flips the starting symbol on the next game. (Eg. if X started first, then O will start in the next game, ...)
Let's implement the ScoreBoard
class and pass it down as an instance to this game.
class ScoreBoard {
constructor(container) {
if (!(container instanceof HTMLElement)) {
throw new Error("Container needs to be a HTML element");
}
this.container = container;
this.scoreBoard = {};
this.currentRound = 0;
}
init(players) {
this.players = players;
this.renderScoreBoard();
}
renderPlayerHeader(name) {
const player = document.createElement("div");
player.classList.add("player");
player.setAttribute("data-player", name);
player.innerHTML = `<span>${name}</span><span class="score">0</span>`;
return player;
}
renderScoreBoard() {
const scoreBoardContainer = document.createElement("div");
scoreBoardContainer.classList.add("scoreBoard-container");
scoreBoardContainer.style.display = "flex";
scoreBoardContainer.style.flexDirection = "row";
const playersHeader = document.createElement("div");
this.players.forEach((player) => {
playersHeader.appendChild(this.renderPlayerHeader(player));
});
const gameMessage = document.createElement("p");
gameMessage.id = "display";
scoreBoardContainer.appendChild(playersHeader);
scoreBoardContainer.appendChild(gameMessage);
this.container.appendChild(scoreBoardContainer);
}
display(message) {
this.container.querySelector("#display").innerHTML = message;
}
updateScore(player) {
this.scoreBoard[player] = (this.scoreBoard[player] || 0) + 1;
this.container.querySelector(
`[data-player="${player}"] > .score`
).innerHTML = this.scoreBoard[player];
}
log(type, player) {
if (type === "winner") {
this.updateScore(player);
this.display(`${player} is the winner of this match`);
} else if (type === "tie") {
this.display("Match ended in a tie");
} else if (type === "start") {
this.display(`Round ${++this.currentRound}`);
}
}
}
new TicTacToe(
document.getElementById("gameContainer"),
new ScoreBoard(document.getElementById("score"))
);
The benefit of writing the code as multiple small functions is it is generally easier to unit-test them; it becomes easier to manage; it becomes easier to read and consume and also during interviews it kind of fills the time in case you are unable to finish it, you can just say I would have a method called getText
which I would assume would get the text to move forward in the interview.
You can find the entire code in those live running code sandbox link - https://codesandbox.io/s/tic-tac-toe-cv068c
It was fun to implement a Tic Tac Toe using javascript after a really long time. Please let me know if you learned a thing or two from this. I hope it is useful.
Please do read my other articles as well if you like this.
Cheers,
Arunkumar Sri Sailapathi.