In this article, we will develop a tic-tac-toe game from scratch using Vue. We will integrate the real time feature with socket.io, so that two players can play the game from different browsers at the same time.
Video Tutorial
Project creation
First, create a blank Vue project and in the app.vue,remove the hello world component and add the html for the grid. I copied the CSS from this tutorial.
We will define 9 blocks with id block_0 to block_8 with class block for each block.
<template> | |
<!-- https://dev.to/ayushmanbthakur/how-to-make-tic-tac-toe-in-browser-with-html-css-and-js-28ed--> | |
<div class="container"> | |
<h1>Tic-Tac-Toe</h1> | |
<div class="play-area"> | |
<div id="block_0" class="block">X</div> | |
<div id="block_1" class="block">O</div> | |
<div id="block_2" class="block"></div> | |
<div id="block_3" class="block"></div> | |
<div id="block_4" class="block"></div> | |
<div id="block_5" class="block"></div> | |
<div id="block_6" class="block"></div> | |
<div id="block_7" class="block"></div> | |
<div id="block_8" class="block"></div> | |
</div> | |
</div> | |
</template> | |
<script> | |
export default { | |
name: 'App', | |
components: { | |
}, | |
data() { | |
return { | |
} | |
}, | |
methods: {} | |
} | |
</script> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
font-family: Arial, Helvetica, sans-serif; | |
} | |
.container { | |
min-height: 100vh; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
background: #eee; | |
} | |
h1 { | |
font-size: 5rem; | |
margin-bottom: 0.5em; | |
} | |
h2 { | |
margin-top: 1em; | |
font-size: 2rem; | |
margin-bottom: 0.5em; | |
} | |
.play-area { | |
display: grid; | |
width: 300px; | |
height: 300px; | |
grid-template-columns: auto auto auto; | |
} | |
.block { | |
display: flex; | |
width: 100px; | |
height: 100px; | |
align-items: center; | |
justify-content: center; | |
font-size: 3rem; | |
font-weight: bold; | |
border: 3px solid black; | |
transition: background 0.2s ease-in-out; | |
} | |
.block:hover { | |
cursor: pointer; | |
background: #0ff30f; | |
} | |
.occupied:hover { | |
background: #ff3a3a; | |
} | |
.win { | |
background: #0ff30f; | |
} | |
.win:hover { | |
background: #0ff30f; | |
} | |
#block_0, | |
#block_1, | |
#block_2 { | |
border-top: none; | |
} | |
#block_0, | |
#block_3, | |
#block_6 { | |
border-left: none; | |
} | |
#block_6, | |
#block_7, | |
#block_8 { | |
border-bottom: none; | |
} | |
#block_2, | |
#block_5, | |
#block_8 { | |
border-right: none; | |
} | |
button { | |
outline: none; | |
border: 4px solid green; | |
padding: 10px 20px; | |
font-size: 1rem; | |
font-weight: bold; | |
background: none; | |
transition: all 0.2s ease-in-out; | |
} | |
button:hover { | |
cursor: pointer; | |
background: green; | |
color: white; | |
} | |
.playerWin { | |
color: green; | |
} | |
.computerWin { | |
color: red; | |
} | |
.draw { | |
color: orangered; | |
} | |
@media only screen and (max-width: 600px) { | |
h1 { | |
font-size: 3rem; | |
margin-bottom: 0.5em; | |
} | |
h2 { | |
margin-top: 1em; | |
font-size: 1.3rem; | |
} | |
} | |
</style> |
You will see the result like this.
You can find the Github code till now in this branch.
GitHub - nilmadhab/tic-tac-toe-youtube at grid-setup
Draw X and O on click
Now, we will define two variables in the data section:
content
-
turn
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersdata() { return { content: ["", "", "", "", "", "", "", "", ""], turn: true, } }, This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters<div id="block_0" class="block" @click="draw(0)">{{ content[0] }}</div> <div id="block_1" class="block" @click="draw(1)">{{ content[1] }}</div> <div id="block_2" class="block" @click="draw(2)">{{ content[2] }}</div> <div id="block_3" class="block" @click="draw(3)">{{ content[3] }}</div> <div id="block_4" class="block" @click="draw(4)">{{ content[4] }}</div> <div id="block_5" class="block" @click="draw(5)">{{ content[5] }}</div> <div id="block_6" class="block" @click="draw(6)">{{ content[6] }}</div> <div id="block_7" class="block" @click="draw(7)">{{ content[7] }}</div> <div id="block_8" class="block" @click="draw(8)">{{ content[8] }}</div>
Now, let’s define the draw function in the methodsection. If the value of turnis true, we will draw X, else we will draw O and change the value of turn. So, first click we draw X andturn becomes false. So, on the second click, we draw O and turn becomes true and so on..
methods: { | |
draw(index) { | |
// send event to socket.io | |
if(this.turn) { | |
// if turn is true then mark as X or mark as O | |
this.content[index] = "X" | |
} else { | |
this.content[index] = "O" | |
} | |
this.turn = !this.turn; | |
}, | |
} |

Calculate Winner
Now, after every call in the draw function, we have to calculate if the game is finished or not. If finished, we can find who is the winner and display it.
We will declare three variables more in the data section.
data() { | |
return { | |
content: ["", "", "", "", "", "", "", "", ""], | |
turn: true, | |
// new variables | |
isOver: false, | |
winner: null, | |
isTie: false | |
} | |
}, |
In the template section, we will add two h2 tags to declare the winner or a tie.
<template> | |
<div class="container"> | |
<h1>Tic-Tac-Toe</h1> | |
<div class="play-area"> | |
<div id="block_0" class="block" @click="draw(0)">{{ content[0] }}</div> | |
<div id="block_1" class="block" @click="draw(1)">{{ content[1] }}</div> | |
<div id="block_2" class="block" @click="draw(2)">{{ content[2] }}</div> | |
<div id="block_3" class="block" @click="draw(3)">{{ content[3] }}</div> | |
<div id="block_4" class="block" @click="draw(4)">{{ content[4] }}</div> | |
<div id="block_5" class="block" @click="draw(5)">{{ content[5] }}</div> | |
<div id="block_6" class="block" @click="draw(6)">{{ content[6] }}</div> | |
<div id="block_7" class="block" @click="draw(7)">{{ content[7] }}</div> | |
<div id="block_8" class="block" @click="draw(8)">{{ content[8] }}</div> | |
</div> | |
<!-- New h2 tags --> | |
<h2 id="winner" v-if="isOver"> Winner is {{winner}} </h2> | |
<h2 v-if="isTie"> Game is Tie</h2> | |
</div> | |
</template> |
Now, we are ready to define calculateWinner function. The logic is if the same row, column or diagonals are occupied by the same player, he/she wins.
calculateWinner() { | |
const WIN_CONDITIONS = [ | |
// rows | |
[0, 1, 2], [3, 4, 5], [6, 7, 8], | |
// cols | |
[0, 3, 6], [1, 4, 7], [2, 5, 8], | |
// diagonals | |
[0, 4, 8], [2, 4, 6] | |
]; | |
for (let i = 0; i < WIN_CONDITIONS.length; i++) { | |
let firstIndex = WIN_CONDITIONS[i][0]; | |
let secondIndex = WIN_CONDITIONS[i][1]; | |
let thirdIndex = WIN_CONDITIONS[i][2]; | |
if(this.content[firstIndex] == this.content[secondIndex] && | |
this.content[firstIndex] == this.content[thirdIndex] && | |
this.content[firstIndex] != "") { | |
this.isOver = true; | |
this.winner = this.content[firstIndex]; | |
} | |
} | |
}, |
We call this function, every time we draw.
draw(index) { | |
// send event to socket.io | |
if(this.turn) { | |
// if turn is true then mark as X or mark as O | |
this.content[index] = "X" | |
} else { | |
this.content[index] = "O" | |
} | |
this.turn = !this.turn; | |
// calculate the winner | |
this.calculateWinner(); | |
}, |
Calculate Tie
Now we will define tie function. The logic is even if there is an empty block, the game is not tied.
calculateTie(){ | |
for (let i = 0 ; i<= 8 ; i++) { | |
if(this.content[i] == "") { | |
return | |
} | |
} | |
this.isTie = true | |
}, |
We will define this function is method section and call it from draw method.
Entire script section till now.
<script> | |
export default { | |
name: 'App', | |
components: { | |
}, | |
data() { | |
return { | |
content: ["", "", "", "", "", "", "", "", ""], | |
turn: true, | |
isOver: false, | |
winner: null, | |
isTie: false | |
} | |
}, | |
methods: { | |
draw(index) { | |
// send event to socket.io | |
if(this.turn) { | |
// if turn is true then mark as X or mark as O | |
this.content[index] = "X" | |
} else { | |
this.content[index] = "O" | |
} | |
this.turn = !this.turn; | |
// calculate the winner | |
this.calculateWinner(); | |
this.calculateTie() | |
}, | |
calculateWinner() { | |
const WIN_CONDITIONS = [ | |
// rows | |
[0, 1, 2], [3, 4, 5], [6, 7, 8], | |
// cols | |
[0, 3, 6], [1, 4, 7], [2, 5, 8], | |
// diagonals | |
[0, 4, 8], [2, 4, 6] | |
]; | |
for (let i = 0; i < WIN_CONDITIONS.length; i++) { | |
let firstIndex = WIN_CONDITIONS[i][0]; | |
let secondIndex = WIN_CONDITIONS[i][1]; | |
let thirdIndex = WIN_CONDITIONS[i][2]; | |
if(this.content[firstIndex] == this.content[secondIndex] && | |
this.content[firstIndex] == this.content[thirdIndex] && | |
this.content[firstIndex] != "") { | |
this.isOver = true; | |
this.winner = this.content[firstIndex]; | |
} | |
} | |
}, | |
calculateTie(){ | |
for (let i = 0 ; i<= 8 ; i++) { | |
if(this.content[i] == "") { | |
return | |
} | |
} | |
this.isTie = true | |
}, | |
} | |
} | |
</script> |

Reset Board
Now, when the game is tied or over, we have to show an option to reset the board.
<template> | |
<div class="container"> | |
<h1>Tic-Tac-Toe</h1> | |
<div class="play-area"> | |
<div id="block_0" class="block" @click="draw(0)">{{ content[0] }}</div> | |
<div id="block_1" class="block" @click="draw(1)">{{ content[1] }}</div> | |
<div id="block_2" class="block" @click="draw(2)">{{ content[2] }}</div> | |
<div id="block_3" class="block" @click="draw(3)">{{ content[3] }}</div> | |
<div id="block_4" class="block" @click="draw(4)">{{ content[4] }}</div> | |
<div id="block_5" class="block" @click="draw(5)">{{ content[5] }}</div> | |
<div id="block_6" class="block" @click="draw(6)">{{ content[6] }}</div> | |
<div id="block_7" class="block" @click="draw(7)">{{ content[7] }}</div> | |
<div id="block_8" class="block" @click="draw(8)">{{ content[8] }}</div> | |
</div> | |
<h2 id="winner" v-if="isOver"> Winner is {{winner}} </h2> | |
<h2 v-if="isTie"> Game is Tie</h2> | |
<!-- Reset board--> | |
<button @click="resetBoard()" v-if="isOver || isTie">RESET BOARD</button> | |
</div> | |
</template> |
We will define the resetBoard function next. We reset the content array and all the other variables.
resetBoard() { | |
for (let i=0; i<= 8; i++) { | |
this.content[i] = "" | |
this.isOver = false; | |
this.winner = null | |
this.isTie = false | |
} | |
} |

Github code till now.
GitHub - nilmadhab/tic-tac-toe-youtube at game-logic-implemented
Multiplayer mode Using Socket.io
Now, we will integrate the project with Socket.io, so that two players can play the game at the same time. When one player clicks X, it should appear on the second player’s screen and when the second player clicks O, it should appear on the first player’s screen. How to implement it?
Here, socket.IO comes handy. The documentation says,
If you want to watch the video tutorial, you can download the above branch and fast forward the video to 35:42 mins.
Set up server for socket.io
We will first create a folder outside the Vue project. Create a file server.js inside the folder. We will create an express server inside the folder.
Run npm init. It will set apackage.json file.
Then run
npm i socket.io
It will install socket.io in the project.
server.js
Now. lets create a server and integrate socket.io.
const server = require('http').createServer() | |
const io = require('socket.io')(server, { | |
cors: { | |
origin: "http://localhost:8080", | |
methods: ["GET", "POST"] | |
} | |
}); | |
io.on('connection', (socket)=> { | |
socket.emit("hello", "youtube tutorial"); | |
}) | |
server.listen(3000) |
We will set cors rule, so that our vue.js project running on port 8080 can access the server.
We will emit an event from the server and our Vue client should listen to it and receive it.
Run the server with
node server.js
App.vue
Now, we will set up socket.io in client side.
Run
npm i socket.io-client
inside the vue.js project from terminal.
We will import the library by
import io from ‘socket.io-client’
const socket = io(“[http://localhost:3000](http://localhost:3000)")
inside the script section.
In the created hook, we will listen to the event.
created() { | |
socket.on("hello", (msg) => { | |
console.log("received msg from server", msg) | |
} |
You will see “youtube tutorial” will appear in the console.
The client can also talk to the server in the same way.
Game Logic with Socket.io
After we call the draw function, player 1 client will send the event to the server.
When the server receives it, it will broadcast it to the player 2.
Player 2 will then update the grid.
Then player 2 will click O and call the draw function, it will send the event to the server.
The server will broadcast it to player 1.
The game will keep going like this.
draw(index, drawFromOther) { | |
// send event to socket.io | |
if(this.turn) { | |
// if turn is true then mark as X or mark as O | |
this.content[index] = "X" | |
} else { | |
this.content[index] = "O" | |
} | |
if (!drawFromOther) { | |
socket.emit("play", index) | |
} | |
this.turn = !this.turn; | |
this.calculateWinner(); | |
this.calculateTie(); | |
}, |
Now, we will update the template. We will send the drawFromOtheras false, which means the event will be sent to the server.
<template> | |
<div class="container"> | |
<h1>Tic tac toe</h1> | |
<div class="play-area"> | |
<div id="block_0" class="block" @click="draw(0, false)">{{ content[0] }}</div> | |
<div id="block_1" class="block" @click="draw(1, false)">{{ content[1] }}</div> | |
<div id="block_2" class="block" @click="draw(2, false)">{{ content[2] }}</div> | |
<div id="block_3" class="block" @click="draw(3, false)">{{ content[3] }}</div> | |
<div id="block_4" class="block" @click="draw(4, false)">{{ content[4] }}</div> | |
<div id="block_5" class="block" @click="draw(5, false)">{{ content[5] }}</div> | |
<div id="block_6" class="block" @click="draw(6, false)">{{ content[6] }}</div> | |
<div id="block_7" class="block" @click="draw(7, false)">{{ content[7] }}</div> | |
<div id="block_8" class="block" @click="draw(8, false)">{{ content[8] }}</div> | |
</div> | |
<h2 id="winner" v-if="isOver"> Winner is {{winner}} </h2> | |
<h2 v-if="isTie"> Game is Tie</h2> | |
<button @click="resetBoard()" v-if="isOver || isTie">RESET BOARD</button> | |
</div> | |
</template> |
Server.jswill receive the event and broadcast it.
const server = require('http').createServer() | |
const io = require('socket.io')(server, { | |
cors: { | |
origin: "http://localhost:8080", | |
methods: ["GET", "POST"] | |
} | |
}); | |
io.on('connection', (socket)=> { | |
socket.emit("hello", "youtube tutorial"); | |
// receive the event and broadcast to other clients | |
socket.on("play", index => { | |
console.log("server received", index) | |
socket.broadcast.emit("play", index) | |
}) | |
}) | |
server.listen(3000) |
Now, the client will receive the event in created hook.
created() { | |
socket.on("play", (index) => { | |
console.log("received index", index) | |
this.draw(index, true) | |
}) | |
} |
It will receive the event and draw at the index, but we pass drawFromOther param as true, so that the event does not again sent to the server.
Complete code of App.vue
<template> | |
<div class="container"> | |
<h1>Tic tac toe</h1> | |
<div class="play-area"> | |
<div id="block_0" class="block" @click="draw(0, false)">{{ content[0] }}</div> | |
<div id="block_1" class="block" @click="draw(1, false)">{{ content[1] }}</div> | |
<div id="block_2" class="block" @click="draw(2, false)">{{ content[2] }}</div> | |
<div id="block_3" class="block" @click="draw(3, false)">{{ content[3] }}</div> | |
<div id="block_4" class="block" @click="draw(4, false)">{{ content[4] }}</div> | |
<div id="block_5" class="block" @click="draw(5, false)">{{ content[5] }}</div> | |
<div id="block_6" class="block" @click="draw(6, false)">{{ content[6] }}</div> | |
<div id="block_7" class="block" @click="draw(7, false)">{{ content[7] }}</div> | |
<div id="block_8" class="block" @click="draw(8, false)">{{ content[8] }}</div> | |
</div> | |
<h2 id="winner" v-if="isOver"> Winner is {{winner}} </h2> | |
<h2 v-if="isTie"> Game is Tie</h2> | |
<button @click="resetBoard()" v-if="isOver || isTie">RESET BOARD</button> | |
</div> | |
</template> | |
<script> | |
import io from 'socket.io-client' | |
const socket = io("http://localhost:3000") | |
export default { | |
name: 'App', | |
components: { | |
}, | |
data() { | |
return { | |
content: ["", "", "", "", "", "", "", "", ""], | |
turn: true, | |
isOver: false, | |
winner: null, | |
isTie: false | |
} | |
}, | |
methods: { | |
draw(index, drawFromOther) { | |
// send event to socket.io | |
if(this.turn) { | |
// if turn is true then mark as X or mark as O | |
this.content[index] = "X" | |
} else { | |
this.content[index] = "O" | |
} | |
if (!drawFromOther) { | |
socket.emit("play", index) | |
} | |
this.turn = !this.turn; | |
this.calculateWinner(); | |
this.calculateTie(); | |
}, | |
calculateWinner() { | |
const WIN_CONDITIONS = [ | |
// rows | |
[0, 1, 2], [3, 4, 5], [6, 7, 8], | |
// cols | |
[0, 3, 6], [1, 4, 7], [2, 5, 8], | |
// diagonals | |
[0, 4, 8], [2, 4, 6] | |
]; | |
for (let i = 0; i < WIN_CONDITIONS.length; i++) { | |
let firstIndex = WIN_CONDITIONS[i][0]; | |
let secondIndex = WIN_CONDITIONS[i][1]; | |
let thirdIndex = WIN_CONDITIONS[i][2]; | |
if(this.content[firstIndex] == this.content[secondIndex] && | |
this.content[firstIndex] == this.content[thirdIndex] && | |
this.content[firstIndex] != "") { | |
this.isOver = true; | |
this.winner = this.content[firstIndex]; | |
} | |
} | |
}, | |
calculateTie(){ | |
for (let i = 0 ; i<= 8 ; i++) { | |
if(this.content[i] == "") { | |
return | |
} | |
} | |
this.isTie = true | |
}, | |
resetBoard() { | |
for (let i=0; i<= 8; i++) { | |
this.content[i] = "" | |
this.isOver = false; | |
this.winner = null | |
this.isTie = false | |
} | |
} | |
}, | |
created() { | |
socket.on("play", (index) => { | |
console.log("received index", index) | |
this.draw(index, true) | |
}) | |
} | |
} | |
</script> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
font-family: Arial, Helvetica, sans-serif; | |
} | |
.container { | |
min-height: 100vh; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
background: #eee; | |
} | |
h1 { | |
font-size: 5rem; | |
margin-bottom: 0.5em; | |
} | |
h2 { | |
margin-top: 1em; | |
font-size: 2rem; | |
margin-bottom: 0.5em; | |
} | |
.play-area { | |
display: grid; | |
width: 300px; | |
height: 300px; | |
grid-template-columns: auto auto auto; | |
} | |
.block { | |
display: flex; | |
width: 100px; | |
height: 100px; | |
align-items: center; | |
justify-content: center; | |
font-size: 3rem; | |
font-weight: bold; | |
border: 3px solid black; | |
transition: background 0.2s ease-in-out; | |
} | |
.block:hover { | |
cursor: pointer; | |
background: #0ff30f; | |
} | |
.occupied:hover { | |
background: #ff3a3a; | |
} | |
.win { | |
background: #0ff30f; | |
} | |
.win:hover { | |
background: #0ff30f; | |
} | |
#block_0, | |
#block_1, | |
#block_2 { | |
border-top: none; | |
} | |
#block_0, | |
#block_3, | |
#block_6 { | |
border-left: none; | |
} | |
#block_6, | |
#block_7, | |
#block_8 { | |
border-bottom: none; | |
} | |
#block_2, | |
#block_5, | |
#block_8 { | |
border-right: none; | |
} | |
button { | |
outline: none; | |
border: 4px solid green; | |
padding: 10px 20px; | |
font-size: 1rem; | |
font-weight: bold; | |
background: none; | |
transition: all 0.2s ease-in-out; | |
} | |
button:hover { | |
cursor: pointer; | |
background: green; | |
color: white; | |
} | |
.playerWin { | |
color: green; | |
} | |
.computerWin { | |
color: red; | |
} | |
.draw { | |
color: orangered; | |
} | |
@media only screen and (max-width: 600px) { | |
h1 { | |
font-size: 3rem; | |
margin-bottom: 0.5em; | |
} | |
h2 { | |
margin-top: 1em; | |
font-size: 1.3rem; | |
} | |
} | |
</style> |
That's it. The multiplayer game is ready to be played. Open localhost:8080 in two different browsers and click alternatively. The game should work.
Top comments (0)