Introduction
In this article you will learn how to create a simple tic-tac-toe game using VueJS. In order to fully understand this article you will need to know basic VueJS, HTML and JavaScript. You will also need to know the JavaScript's promises with its async/await functions. By the end of this tutorial the website will look like the image below
Building the game board.
To start off you should create a project directory tic-tac-toe
(you can call it whatever you want. Inside the project directory, create a file index.html
. Also create another folder scripts
this will be where we keep the vue.min.js
and the vue.dev.js
files (this part is optional, and if you want you can use the cdn link at cdn.jsdelivr.net/npm/vue@2.6.14).
In index.html
file write;
<!DOCTYPE html>
<html lang="en">
<head>
<title>TicTacToe</title>
<script src="./scripts/vue.dev.js"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="App">
<div class="Game">
<table class="GameBoard r-t-l r-t-r r-b-l r-b-r">
<tbody>
<tr>
<td class="cell r-t-l"></td>
<td class="cell"></td>
<td class="cell r-t-r"></td>
</tr>
<tr>
<td class="cell"></td>
<td class="cell"></td>
<td class="cell"></td>
</tr>
<tr>
<td class="cell r-b-l"></td>
<td class="cell"></td>
<td class="cell r-b-r"></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
If you are using the vue cdn change the src
attribute of the script tag on line 6 to "cdn.jsdelivr.net/npm/vue@2.6.14"
.
From the 9th - 31st line the div
with the id App
is the app container where all the vue operations will take place.
On the 10th line the div
element of class Game
is used by the stylesheet only to center the board on the screen.
The r-t-l
, r-t-r
, r-b-l
and r-b-r
classes are only used by the stylesheet for rounding the edge they represent in the element.
- r-t-l means round-top-left border,
- r-t-r means round-top-right border,
- r-b-l means round-bottom-left border, and
- r-b-r means round-bottom-right border.
Under the closing div tag for the element class Game
(line 30) add a div element of class Text
. This will be where the game commentary will be. So, to make it work we will add the text {{ text }}
inside this tag.
Then under the newly created Text
tag we will add another div with the class Button
. Inside this tag we will write <button id="ResetButton">Reset</button>
. This will be the button that will be used to reset the game. The div container around the reset button is used by the stylesheet to center the button to place.
In total, the code that will be added below the line 30 is;
<div class="Text">{{ text }}</div>
<div class="Button">
<button id="ResetButton">Reset</button>
</div>
To enable the board to show the X's and the O's, we will need to add in some variables to the inside of the cells of the HTML table, like;
<table class="GameBoard r-t-l r-t-r r-b-l r-b-r">
<tbody>
<tr>
<td class="cell r-t-l">{{ board[0] }}</td>
<td class="cell">{{ board[1] }}</td>
<td class="cell r-t-r">{{ board[2] }}</td>
</tr>
<tr>
<td class="cell">{{ board[3] }}</td>
<td class="cell">{{ board[4] }}</td>
<td class="cell">{{ board[5] }}</td>
</tr>
<tr>
<td class="cell r-b-l">{{ board[6] }}</td>
<td class="cell">{{ board[7] }}</td>
<td class="cell r-b-r">{{ board[8] }}</td>
</tr>
</tbody>
</table>
The {{ board[0] }}
additions will enable vue display changes in the data.
Adding the styling
Create a file called style.css
, Then copy and paste the code below to the file
#App {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
justify-content: space-around;
}
#App .Game, .Button {
display: flex;
justify-content: space-around;
}
.GameBoard {
width: 300px;
height: 300px;
background: linear-gradient(purple, blue);
box-shadow: 0px 5px 10px rgb(138, 89, 138);
}
.GameBoard .cell {
text-align: center;
border: none;
width: 95px;
height: 95px;
background-color: whitesmoke;
}
body {
padding: 0px;
margin: 0px;
background-color: whitesmoke;
}
.Text {
text-align: center;
font-size: x-large;
font-weight: 900;
}
#ResetButton {
background-color: rgb(0, 200, 255);
font-size: large;
font-weight: 900;
border-radius: 5px;
color: white;
border: 10px;
padding: 15px;
}
#ResetButton:hover {
background-color: rgb(0, 104, 133);
}
.r-t-l {
border-top-left-radius: 20px;
}
.r-t-r {
border-top-right-radius: 20px;
}
.r-b-l {
border-bottom-left-radius: 20px;
}
.r-b-r {
border-bottom-right-radius: 20px;
}
* {
font-family: sans-serif;
}
Adding the script.
To start of we will add a script tag inside the bottom of the body
element (it must be at the bottom for it to work).
Insert the following code in the script tag;
<script>
let data = {
board: [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
text: "",
};
new Vue({
el: "#App",
data,});
</script>
add a load
event listener to the windows windows.addEventListener('load', () => main());
(put it at the bottom of the script tag). Then create a function called main on the top of the script (this is where all the logic for the game is handled). It is going to be a recursive function that gets called again every time the reset button is clicked.
Because we will be implementing the player operations using asynchronous functions we have to make the main
function asynchronous. To make a function asynchronous, we put async
in the front of the function definition like so;
async function main() {
Then in between the parenthesis of the main function, add the argument swap=false
. This will be used to swap which player goes first every time the game is reset. The main function definition should look like the following now;
async function main(swap=false) {
In the first two lines of the main function type in
let player1 = !swap ? 'x' : 'o';
let player2 = !swap ? 'o' : 'x';
This fragment responds to the swap
argument passed to main. It swaps the player 1 position every time the game is reset
The script tag should be something like the following now;
<script>
> async function main(swap=false) {
> let player1 = !swap ? 'x' : 'o';
> let player2 = !swap ? 'o' : 'o';
> }
let data = {
board: [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
text: "",
};
new Vue({
el: "#App",
data,});
> window.addEventListener('load', () => main());
</script>
">" means that the line was just added
If you look at the game board from your browser at this point a Reset
button would under it. We wouldn't want it there until the game is finished like;
So we create a function deactivateResetButton()
function deactivateResetButton() {
document.getElementById("ResetButton").style.visibility = "hidden";
}
We will also need a function that activates the reset button when the game is over so it will be called activateResetButton
. This function will be used later in this tutorial.
function activateResetButton() {
document.getElementById("ResetButton").style.visibility = "visible";
}
To keep our code clean, we need to remove repitions. The code fragment document.getElementById("ResetButton").style.visibility
appears more than once. We could make this fragment into a function that sets the visibility, but since we will also be creating an event listener later in this article we will only create a function getResetButton()
that returns a pointer to the HTML element.
function getResetButton() {
return document.getElementById("ResetButton");
}
and update the two functions like so
function deactivateResetButton() {
> getResetButton().style.visibility = "hidden";
}
function activateResetButton() {
> getResetButton().style.visibility = "visible";
}
The updated script tag will now be
<script>
async function main(swap=false) {
let player1 = !swap ? 'x' : 'o';
let player2 = !swap ? 'o' : 'o';
> deactivateResetButton();
}
> function getResetButton() {
> return document.getElementById("ResetButton");
> }
> function deactivateResetButton() {
> getResetButton().style.visibility = "hidden";
> }
> function activateResetButton() {
> getResetButton().style.visibility = "visible";
> }
let data = {
...
</script>
Keep in mind that the ">" is not part of the code
Now it is time to add some player action to the game. We do that by inserting
while (getEmptySpacesPos(data.board).length !== 0) {
await watchPlayer(player1);
if (playerWon(player1)) {break;};
await watchPlayer(player2);
if(playerWon(player2)) {break;};
}
to the main function. This is where the player logic is handled
- on the 1st line of this fragment the function
getEmptySpacesPos
gets a list of all the positions in the board that are empty. Then the while block uses the function to check if the list is still not empty when it has finished responding to the players. - on the 2nd and 5th line of the fragment the function
watchPlayer
is asynchronous so themain
function waits for its response before proceeding with the program. ThewatchPlayer
waits for a player to click on the board, then it gets the cell position that was clicked and updates the board with the player character passed as the argument for reference. and the 3rd and 6th line, the
playerWon
function check if the player passed as the argument has won the match. If the player wins, the loop is exited and the game is over.The the code fragment above depends on the
getEmptySpacesPos
,watchPlayer
and theplayerWon
function:
function getEmptySpacesPos(board) {
if (board.length === 0) return [];
else {
let head = board[0];
let tail = board.slice(1, board.length);
let pos = 9 - board.length;
if (head === ' ') return [pos, ...getEmptySpacesPos(tail)];
else return getEmptySpacesPos(tail)
}
}
This function get the list of the positions of empty spaces in the board. The argument board
of this function contains the board data. The function is a recursive function that seperates the first element head
(4th line) of the board from the rest of the board in tail
(5th line), then it checks the head
for if it contains a space. If it does, it adds the position of the element to the list of empty-space positions and applies the same function to tail
. And if it doesn't, it does not add the position to the list and continues to apply the function to tail
. The spread operator on the 7th line is used to add the results of the recurring functions to one list and prevents it from giving us a multi-dimensional array whenever we call the function.
async function watchPlayer(character) {
updateText("Player " + character.toUpperCase() + " turn.");
let emptySpaces = getEmptySpacesPos(data.board);
let pos = await playerMove(character, emptySpaces);
updateBoard(pos, character);
}
This function waits for the user to click on the board and then it responds to it.
- First, it declares that the it is the
character
's turn to play (2nd line). - Then it check for all the empty spaces in the board so that the players don't overwrite a non-empty space (3rd line).
- After that, it waits for the player to make a move. When the player has make a move, it saves that position to
pos
(4th line). - And finally, the function
updateBoard
updates the board using thepos
and the playercharacter
to know where to place the which character in the board data (5th line).
function playerWon(playerCharacter, board=data.board, stats={ h1: 0, h2: 0, h3: 0, v1: 0, v2: 0, v3: 0, d1: 0, d2: 0}) {
if (board.length === 0) {
return Object.keys(stats).filter(key => stats[key] === 3 ).length === 1;
} else {
let head = board[0];
let tail = board.slice(1, board.length);
let position = 9 - board.length;
let newStat = {...stats}
let statsToAddTo = getStatsToAddTO(position);
statsToAddTo.forEach(prop => {
if (head === playerCharacter) { newStat[prop]++ };
});
return playerWon(playerCharacter, tail, newStat);
}
}
Finally, the playerWon
function is also recursive and it takes in the playerCharacter
(either 'x' or 'o') as the first argument, board
as the second and stats
as the third argument (for the sake of simplicity they are optional and will be ignored when calling the function in our code). The function checks the board data for the presence of all the playerCharacter
('x' if the player character is 'x' and 'o' if the player character is 'o') in the board and uses their position on the board to update the pattern stats and at the end of the program. A pattern is a direction on the board that can contain 3 characters in a row. It then checks the stats
argument for all completed patterns. If one pattern is completed then the player has won and the function returns a boolean true, otherwise, the player has not won the game and it returns a boolean false.
- In the
stats
argument the- h1: means the first row
- h2: means the second row
- h3: means the third row
- v1 - v3: means all the columns from the first to the third.
- d1: means the major diagonal (\)
- d2: means the minor diagonal (/)
- the function works by seperating the first element
head
from the rest of the board datatail
(5th and 6th line). - Then it gets the
position
of thehead
in the board at the 7th line. - At the 9th line a clone of the stats is created as
newStat
to prevent any potential side effects. - After, it uses the
position
variable to get the list of stats to increment on line 10. The type of stat to increment will always depend on the position of the character in the board. For example: The first cell in the board lies on the first row, the first column and the major diagonal. So the list of stats that will be incremented are h1, v1 and d1. - On lines 11 - 13 the list of stats to be incremented is used to know the stat to increment in the
newStat
variable. The updatednewStat
variable is then passed to the function again in the next iteration using the remaining part of the body,tail
, as the newboard
argument.. The same playerCharacter is used all through out the recurring function calls. Finally, on the 3rd line, we iterate the
stats
argument. Because JavaScript doesnt have a shorter object iteration, we have to get a list of the keys first by using theObject.keys
function on thestats
argument and then using thestats[key]
to reference the each element elements). After the iteration, we filter all the completed patterns with... ).filter(key => stat[key] === 3)
(all completed patterns will appear as the number 3 in the stats object). And to finish it all, it checks if there is a completed pattern with... ).length === 1;
. This line is explained last because it is only executed when the function is done iterating theboard
dataAt this point the script should look something like
... let player2 = !swap ? 'o' : 'o'; deactivateResetButton(); > while (getEmptySpacesPos(data.board).length !== 0) { > await watchPlayer(player1); > if (playerWon(player1)) {break;}; > > await watchPlayer(player2); > if(playerWon(player2)) {break;}; > } } > function getEmptySpacesPos(board) { > if (board.length === 0) return []; > else { > let head = board[0]; > let tail = board.slice(1, board.length); > let pos = 9 - board.length; > if (head === ' ') return [pos, ...getEmptySpacesPos(tail)]; > else return getEmptySpacesPos(tail) > } > } > async function watchPlayer(character) { > updateText("Player " + character.toUpperCase() + " turn."); > let emptySpaces = getEmptySpacesPos(data.board); > let pos = await playerMove(character, emptySpaces); > updateBoard(pos, character); > } > function playerWon(playerCharacter, board=data.board, stats={ h1: 0, h2: 0, h3: 0, v1: 0, v2: 0, v3: 0, d1: 0, d2: 0}) { > if (board.length === 0) { > return Object.keys(stats).filter(key => stats[key] === 3 ).length === 1; > } else { > let head = board[0]; > let tail = board.slice(1, board.length); > let position = 9 - board.length; > > let newStat = {...stats} > let statsToAddTo = getStatsToAddTO(position); > statsToAddTo.forEach(prop => { > if (head === playerCharacter) { newStat[prop]++ }; > }); > > return playerWon(playerCharacter, tail, newStat); > } > } function deactivateResetButton() {...} function activateResetButton() {...} let data = { ...
{...}
means that the block was hidden for simplicity.
The last set of functions needed are playerMove
, updateBoard
, updateText
and the getStatsToAddTO
and are defined as;
function playerMove(character, emptySpaces) {
return new Promise((resolve, reject) => {
emptySpaces.forEach(spacePos => {
document
.getElementsByClassName("cell")[spacePos]
.addEventListener('click', () => resolve(spacePos));
});
});
}
This is an asynchronous function that waits for the board to be clicked, then it returns the cell position that was clicked. It takes in the player character
and the list of emptySpaces
as arguments. On the 3rd line, the list of all the empty spaces' positions is iterated and a click event is registered on the cells that are empty to prevent overwriting a cells that aren't. This function returns a promise that waits for the result of the function before proceeding with the program.
function updateBoard(pos, character) {
let newBoard = [...data.board];
newBoard[pos] = character;
data.board = newBoard;
}
The updateBoard
function takes in the position that was clicked by the user, pos
, and the character
to be placed on the board at that position.
- For the board to be updated, a clone of the current board is made using the spread operator to prevent any weird behaviour on the 2nd line.
- Then the character is placed at the position given by
pos
on the clone of the board (newBoard
) on the 3rd line. - And finally, the current board is replaced with the clone the 4th line. When the board data gets replaced the app container gets refreshed and the changes will be seen.
function updateText(text) {
data.text = text;
}
This is one of the simplest function we will be making in this tutorial. What it does is that it changes the text that is going to be shown below the board.
function getStatsToAddTO(position) {
return (position === 0 ? ['h1', 'v1', 'd1'] :
position === 1 ? ['h1', 'v2'] :
position === 2 ? ['h1', 'v3', 'd2'] :
position === 3 ? ['h2', 'v1']:
position === 4 ? ['h2', 'v2', 'd1', 'd2'] :
position === 5 ? ['h2', 'v3'] :
position === 6 ? ['h3', 'v1', 'd2'] :
position === 7 ? ['h3', 'v2'] :
position === 8 ? ['h3', 'v3', 'd1'] : []);
}
This function is used by the playerWon
function to get all the pattern stats
to be increased by 1. For example, on the board, the first cell has position 0 and lies on the first row h1
, first column and the major diagonal d1
, and so on with the rest. Then the list of stats that the positions lie on is returned to the playerWon
function to help it figure out if a player wins.
Now the script tag should look like;
...
let player2 = !swap ? 'o' : 'o';
deactivateResetButton();
while (getEmptySpacesPos(data.board).length !== 0) {
await watchPlayer(player1);
if (playerWon(player1)) {break;};
await watchPlayer(player2);
if(playerWon(player2)) {break;};
}
}
> function playerMove(character, emptySpaces) {
> return new Promise((resolve, reject) => {
> emptySpaces.forEach(spacePos => {
> document
> .getElementsByClassName("cell")[spacePos]
> .addEventListener('click', () => resolve(spacePos));
> });
> });
> }
> function updateBoard(pos, character) {
> let newBoard = [...data.board];
> newBoard[pos] = character;
> data.board = newBoard;
> }
> function updateText(text) {
> data.text = text;
> }
> function getStatsToAddTO(position) {
> return (position === 0 ? ['h1', 'v1', 'd1'] :
> position === 1 ? ['h1', 'v2'] :
> position === 2 ? ['h1', 'v3', 'd2'] :
> position === 3 ? ['h2', 'v1']:
> position === 4 ? ['h2', 'v2', 'd1', 'd2'] :
> position === 5 ? ['h2', 'v3'] :
> position === 6 ? ['h3', 'v1', 'd2'] :
> position === 7 ? ['h3', 'v2'] :
> position === 8 ? ['h3', 'v3', 'd1'] : []);
> }
function getEmptySpacesPos(board) {...}
async function watchPlayer(character) {
...
At this point The game is basically done and you can start playing with it. But then, you might want to give the user the ability to reset the game without having to refresh the page everytime the game is over. So now it is time to add the final feature of the game. The reset button.
Before we start working on the reset button we would want the user to easily see who the winner is. And for the sake of simplicity we wouldn't be drawing the line that goes across the 3 characters in a row. Rather, we would display a text underneath the board that says who won if there is a winner and says that it is a tie if there is no winner.
To do this we will put the function call;
updateText( playerWon(player1)
? "Player " + player1.toUpperCase() + " won!"
: playerWon(player2)
? "Player " + player2.toUpperCase() + " won!"
: "It's a tie!" );
under the while
loop in the main
function. It is placed there because, when the game finishes, the while loop is exited and the runtime environment continues to the next line.
Now that we've added the text, we need to make the reset button functional. To do that we need to add the following code fragment under the last updateText
function;
```JS activateResetButton(); await resetButtonClick(); resetBoard();
return main(!swap); ```JS
In the end the main function should look like this;
async function main(swap=false) {
let player1 = !swap ? 'x' : 'o';
let player2 = !swap ? 'o' : 'x';
deactivateResetButton();
while (getEmptySpacesPos(data.board).length !== 0) {
await watchPlayer(player1);
if (playerWon(player1)) {break;};
await watchPlayer(player2);
if(playerWon(player2)) {break;};
}
> updateText( playerWon(player1)
> ? "Player " + player1.toUpperCase() + " won!"
> : playerWon(player2)
> ? "Player " + player2.toUpperCase() + " won!"
> : "It's a tie!" );
> activateResetButton();
> await resetButtonClick();
> resetBoard();
> return main(!swap);
> }
activateResetButton()
function makes the reset button visible on the screen- Then, the
await resetButtonClick();
function waits for the reset button to be clicked before proceeding with the rest of the code - After waiting for the reset button to be clicked, the game board is reset with the
resetBoard
function - And finally the program starts again on the
return main(!swap);
line, this time, swapping which player goes first. - the definitions supporting the previous functions are
function resetButtonClick() { return new Promise((resolve, reject) => { getResetButton().addEventListener('click', resolve); }); }
This is an asynchronous function that returns a promise. It registers a click
event listener in the reset button to make the code to proceed with it's execution when the button is clicked.
function resetBoard() {
data.board = [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '];
}
This function is very simple and all it does is to replace the current board data with a new and empty one.
Now, we have come to the end of the tutorial and the body tag should look like;
<body>
<div id="App">
<div class="Game">
<table class="GameBoard r-t-l r-t-r r-b-l r-b-r">
<tbody>
<tr>
<td class="cell r-t-l">{{ board[0] }}</td>
<td class="cell">{{ board[1] }}</td>
<td class="cell r-t-r">{{ board[2] }}</td>
</tr>
<tr>
<td class="cell">{{ board[3] }}</td>
<td class="cell">{{ board[4] }}</td>
<td class="cell">{{ board[5] }}</td>
</tr>
<tr>
<td class="cell r-b-l">{{ board[6] }}</td>
<td class="cell">{{ board[7] }}</td>
<td class="cell r-b-r">{{ board[8] }}</td>
</tr>
</tbody>
</table>
</div>
<div class="Text">{{ text }}</div>
<div class="Button">
<button id="ResetButton">Reset</button>
</div>
</div>
<script>
async function main(swap=false) {
let player1 = !swap ? 'x' : 'o';
let player2 = !swap ? 'o' : 'x';
deactivateResetButton();
while (getEmptySpacesPos(data.board).length !== 0) {
await watchPlayer(player1);
if (playerWon(player1)) {break;};
await watchPlayer(player2);
if(playerWon(player2)) {break;};
}
updateText( playerWon(player1)
? "Player " + player1.toUpperCase() + " won!"
: playerWon(player2)
? "Player " + player2.toUpperCase() + " won!"
: "It's a tie!" );
activateResetButton();
await resetButtonClick();
resetBoard();
return main(!swap);
}
let data = {
board: [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
text: "",
}
function resetBoard() {
data.board = [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '];
}
function updateText(text) {
data.text = text;
}
function getResetButton() {
return document.getElementById("ResetButton");
}
function deactivateResetButton() {
getResetButton().style.visibility = "hidden";
}
function activateResetButton() {
getResetButton().style.visibility = "visible";
}
function resetButtonClick() {
return new Promise((resolve, reject) => {
getResetButton().addEventListener('click', resolve);
});
}
function updateBoard(pos, character) {
let newBoard = [...data.board];
newBoard[pos] = character;
data.board = newBoard;
}
async function watchPlayer(character) {
updateText("Player " + character.toUpperCase() + " turn.");
let emptySpaces = getEmptySpacesPos(data.board);
let pos = await playerMove(character, emptySpaces);
updateBoard(pos, character);
}
function playerMove(character, emptySpaces) {
return new Promise((resolve, reject) => {
emptySpaces.forEach(spacePos => {
document
.getElementsByClassName("cell")[spacePos]
.addEventListener('click', () => resolve(spacePos));
});
});
}
function getEmptySpacesPos(board) {
if (board.length === 0) return [];
else {
let head = board[0];
let tail = board.slice(1, board.length);
let pos = 9 - board.length;
if (head === ' ') return [pos, ...getEmptySpacesPos(tail)];
else return getEmptySpacesPos(tail)
}
}
function getStatsToAddTO(position) {
return (position === 0 ? ['h1', 'v1', 'd1'] :
position === 1 ? ['h1', 'v2'] :
position === 2 ? ['h1', 'v3', 'd2'] :
position === 3 ? ['h2', 'v1']:
position === 4 ? ['h2', 'v2', 'd1', 'd2'] :
position === 5 ? ['h2', 'v3'] :
position === 6 ? ['h3', 'v1', 'd2'] :
position === 7 ? ['h3', 'v2'] :
position === 8 ? ['h3', 'v3', 'd1'] : []);
}
function playerWon(playerCharacter, board=data.board, stats={ h1: 0, h2: 0, h3: 0,
v1: 0, v2: 0, v3: 0,
d1: 0, d2: 0})
{
if (board.length === 0) {
return Object.keys(stats).filter(key => stats[key] === 3 ).length === 1;
} else {
let head = board[0];
let tail = board.slice(1, board.length);
let position = 9 - board.length;
let newStat = {...stats}
let statsToAddTo = getStatsToAddTO(position);
statsToAddTo.forEach(prop => {
if (head === playerCharacter) { newStat[prop]++ };
});
return playerWon(playerCharacter, tail, newStat);
}
}
new Vue({
el: "#App",
data,
});
window.addEventListener('load', () => main());
</script>
</body>
Note: The order of the functions were arranged from smallest to biggest
Thanks for reading.