Now we will talk about the fifth project and how to go about it.
We're trying to build a tic-tac-toe game.
Project: TIC TAC TOE,
Time taken: 48+ hours,
Difficulty: Intermediate.
The link to the take home project is here.
Tic-Tac-Toe is basically a two man game where you try to beat your opponent by matching three boxes out of the nine provided boxes and the other tries to block your move while trying to beat you too.
This is one of the possibilities of the game as shown below:
So let's get started!
First I'll say we're using React.js to build our game, for this tutorial we'll use the inline html embedded library.
Your html page may look like this:
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="root">
</div>
<script src="index.js"></script>
</body>
</html>
You can get the embedded inline React from here
So let's move on to our index.js,
class Main extends React.Component{
constructor(props){
super(props);
}
render(){
return(
<div></div>
)
}
}
This would be our main function, but before we get to the game, we might need to welcome our viewers and also give our viewers a choice of playing X or O and maybe if they want to play with a second member or with computer. So we would design three pages in which the previous one will determine the outcome of the next page.
I'll start with the first welcome page which should just show a simple welcome and giving the user the option to choose one player that is vs computer or two players.
The first component will be named Main1 for ease and would need to determine the properties of the next page. There are many ways to do this, we could pass a props to the next component and use react router, if we're using multiple pages. Another is the manipulation of states of external components, which we would use in this article.
But first let's define some variables we would be using,
const wincombos=[[0,1,2],[0,3,6],[3,4,5,],[6,7,8],[1,4,7],[2,4,6],[2,5,8],[0,4,8]]
const p=[0,1,2,3,4,5,6,7,8]
The first constant shows all possible winning combos in the tic-tac-toe game. The second constant is just a referral for the number of grids we have to fill.
Back to the our first welcome component Main1:
class Main1 extends React.Component{
constructor(props){
super(props);
this.doubleUpdate=this.doubleUpdate.bind(this)
}
doubleUpdate(num){
num==1? update('type',true):update('type',false)
update1(num)
update2(1)
}
render(){
return(
<div className='first'>
<h1>Welcome! </h1>
<h3> How do you want to play?</h3>
<div>
<button className='ch' onClick={()=>this.doubleUpdate(1)}>One Player</button>
<button className='ch' onClick={()=>this.doubleUpdate(2)}>Two Players</button>
</div>
</div>
)
}
}
According to the component above, we have two buttons to determine what our next page will render. The function that determines that is the doubleUpdate function, what it does is it changes two states, the state of the main component to trigger it to render the next component after clicking, and also the state of the next rendered component, to determine what it would render to the user. As we see, one player ie. vs computer puts a num 1 as the argument to the function doubleUpdate, the other puts 2.You can decide your own props maybe true or false or anything else, just two things to distinguish which button triggered the click.
The doubleUpdate function uses the number to manipulate the update function to change the state of type in the Main3 component to either true or false. As we noticed, the update function isn't bound to Main1 component, it would be bound to the component to be manipulated. Also another function is called (update2) to change the state of the Main component. Also we want to change the state of Main2, by using update1 function.
So next, we want the player1 to choose between X or O, if they chose two players or allow the solo user to also choose between X or O.
function update1(text){
this.setState({players:text})
}
class Main2 extends React.Component{
constructor(props){
super(props);
this.state={
players:1
}
this.back=this.back.bind(this)
update1=update1.bind(this)
this.double=this.double.bind(this)
}
back(){
update2(0)
}
double(txt){
update('player1',txt);
update2(2)
}
render(){
if (this.state.players==1){
var text='Would you like to be X or O?'
}
else{
var text='Player1: Would you like to be X or O?'
}
return(
<div className='second'>
<h3>{text}</h3>
<div>
<button className='bound' onClick={()=>this.double('X')}>X</button>
<button className='bound' onClick={()=>this.double('O')}>O</button>
</div>
<button onClick={this.back}>Back</button>
</div>
);
}
}
As we see above the update1 function is bound to the Main2 component and used to change the state of Main2. We also created a back function button click to return us to the homepage. The choice of the player is sent to the update function to tell component Main3 as we recall, to use this choice to render a customized page for the user. Also the update2 function as we recall is used to change the Main component which would be rendered to the page. the back function also uses this update2 function to change the state to the previous value thereby rendering the previous page.
So next is the main class that does the bulk of the work,
var turn=false
function update(player,text){
this.setState({[player]:text})
}
var state={
0:'',1:'',2:'',3:'',4:'',
5:'',6:'',7:'',8:'',play:true,
player1:'',player2:'',text:'',
comp:'',score1:0,score2:0,type:true,
array1:[],array2:[],array:[[],[]]
}
class Main3 extends React.Component {
constructor(props){
super(props);
this.state={
0:'',1:'',2:'',3:'',4:'',
5:'',6:'',7:'',8:'',play:true,
player1:'',player2:'',text:'',
comp:'',score1:0,score2:0,type:true,
array1:[],array2:[],array:[[],[]]
}
}
}
Now, I have a habit of keeping a parallel record of the state object when the number of values in the object exceed ten, this helps me reference easily and help me in debugging, normally after all, you should clear it and only use the state object defined in the class but for clarity we'll use it, you can also name the outside object anything, but I called mine state.
Next let's add the render and return,
render() {
return (
<div className='gamer'>
<div className='text'>{this.state.text}</div>
<div className='score'><div>Player1- {this.state.score1}</div> <div>{this.state.type? 'Computer-'+ this.state.score2: 'Player2-'+ this.state.score2}</div></div>
<div className='game'>
{p.map((i,j)=>{
return <div className='tile' key={j} id={j} onClick={this.play}>{this.state[j]}</div>
})}
</div>
</div>
);
}
So, what do we have here, the gamer div is the cover div, the text div, tells if it's a win for you or the other person or a draw, we should notice it's initial value is an empty string. The next is the score div that keeps the total score for us. The game div which is next is forms the boxes on which a person can play, 'p' was defined earlier as an array of numbers 0-8, we map p and create a div each with an onclick listener, we put a function play and the content is the text of the corresponding state.
This means that if we click on box 1 and we are player 'X', the state is changed in the play function and it reflects by the box content changing from empty to 'X'. But that's not only what the play function does.
So let's see how the play function looks like,
play(e){
let board=p.map((j,ind)=>state[ind])
let arr=this.state.array
state.text=''
state.player1=this.state.player1
state.player2=this.state.player1=='X'?'O':'X'
if(board[e.target.id]==''){
if(this.state.type && state.play==true){
this.change(e)
this.win()
this.tie()
if(!this.win() && !this.tie()){
let max=this.ai(e)
this.change(e,max)
this.win()
this.tie()
}
}
else{
this.change(e)
this.win()
this.tie()
}
}
}
Okay, so I'm just going to declare that I'll be using an outside object named state due to ease and explanation but I'll advise you use the this.state object, but sometimes I'll reference the this.state objects is to get the type or to reflect the entire changes done to the outside state object in the this.state object.
So the board, a 9 valued array, initially containing empty strings. The array is an array of two arrays, one array will record the index of boxes clicked for user1, the second will do the same for the second user. This is to avoid playing twice in a box.
So remember you had a choice of X or O in the previous class Main2, so it updated the Main3 class player1 state, so player2 should be the other then, so we're updating the outside state object with the player1 and player2 choices.
The next is an if state that checks if the box is unchecked that is not clicked before, then checks if the player chose single or multiplayer and if single player if it's his turn, type equal to false is for multiplayer here and true for single player with computer, also play equals to true states it's your turn to play.
So let's assume we chose multiplayer, we only have three functions to run which are change, win and tie, these functions change the state of the boxes clicked, check for a win, check for a draw respectively.
So what do our change function, look like?
change(e,max){
let ide=max || e.target.id
var res
let board=p.map((j,ind)=>state[ind])
if(state[ide]===''){
if(state.play===true){
state[ide]=state.player1
res= [[...state.array[0],Number(ide)],[...state.array[1]]]
state.array1=state.array1.concat(Number(ide));state.array=res;state.play=!state.play
this.setState({...state})
}
else{
state[ide]=state.player2
res= [[...this.state.array[0]],[...this.state.array[1],Number(ide)]]
state.array2=state.array2.concat(Number(ide));state.array=res;state.play=!state.play;
this.setState({...state})
}
}
}
So let's break down our change function, at first we gave a non compulsory max argument meaning if there is max fine, else use e.target.id.
We also defined a variable res which we would use later and also extracted all the boards values, filled or not into an array. We're dealing with the ide variable which is either max or the target id, it would first check for the value of max before assigning the event.target.id to the ide variable if it doesn't find a max variable.
Next we check again if the intended box is empty, this is to be double sure that all. Then we check if its our turn to play, true for player1 and false for player2 which could be your opponent or computer. Remember we're in multiplayer and it's our turn to play, what next is it would then fill that particular index(box) with the player's choice which could be X or O. Then the index filled is recorded for crosschecking later and also goes for the array1 of the object, then the play is switched from true to false to give chance for the other player.
Then we change the state of the application by applying all changes made to the state variable with setState. The same process happens for player2, but this time it will be array 2 that will change instead of array1.
Now let's check the win function;
win(){
let arr=state.array
for(let q=0;q<wincombos.length;q++){
if(wincombos[q].every((j)=>arr[0].includes(j))){
wincombos[q].map((i)=>{
let to=document.getElementById(i)
to.classList.add('win')
})
setTimeout(()=>{arr[0].map((i)=>{
let too=document.getElementById(i)
too.classList.remove('win')})
},50)
state.array1=[];state.array2=[];state.score1+=1;state.array=[[],[]];state.text='Player 1 won!';p.map((i,j)=>{state[j]=''})
return true
}
else if(wincombos[q].every((j)=>arr[1].includes(j))){
wincombos[q].map((i)=>{
let to=document.getElementById(i)
to.classList.add('win')
})
setTimeout(()=>{arr[1].map((i)=>{
let too=document.getElementById(i)
too.classList.remove('win')})
},50)
state.array1=[];state.array2=[];state.score2+=1;state.array=[[],[]];state.text=this.state.type?'Computer won!':'Player 2 won!';p.map((i,j)=>{state[j]=''})
return true
}
}
return false;
}
So basically the win function checks if there is a win using the total wincombos we defined before now above as an array of arrays, and check if every element in an wincombos inner array is in the state.array array inner array. Remember the state.array is also an array of array, containing the arrays of indexes played by the two players .
It checks for player one and two, and let's say player 1 won, it would have to indicate how you won by darkening the section that led to your win for a brief moment. So we attach a special class with the attributes described above but we just want it for a short while and not spoil or disfigure our board, so we set a delay of 50 milliseconds to remove it from the board by removing the special class we added before. Then after that we have to clear the board and reset everything, also we would have to indicate who won, so we set the text to player 1 won or player 2 won or computer won depending on the case. The return a true if we saw a winner or false if there isn't
tie(){
let board=p.map((j,ind)=>state[ind])
if(board.filter(i=>i=='').length==0 && !this.win()){
state.array1=[];state.array2=[];state.array=[[],[]];state.text='It was a draw!'
p.map((i,j)=>{state[j]=''})
return true
}
else{return false}
}
The tie simply checks if the board is empty and the win function indicates no winner, then resets the board and tells us it's a draw.
Now with this, we could be done, just a little style and we're done but we want to also add a computer feature that is intelligent enough to know how to play.
Let's look at the function below:
ai(e){
let board=p.map((j,ind)=>state[ind])
let arr=state.array
let m=state.player2
let ini=[0,2,6,8];
if(board.every((j)=>j=='')){
return ini[Math.floor(Math.random()*4)]
}
else if(board.filter((j)=>j!='').length==1){
if(board[4]==''){return 4}
else{return this.minimax(e,board,arr,0,m).index}
}
else if(this.kick()){
//console.log('done')
return this.kick()
}
else{
return this.minimax(e,board,arr,0,m).index
}
}
Now we'll assume computer will always take position player2. We want to implement a minimax algorithm but using it from scratch takes a lot of time and computing power because we're going to use a lot of recursions. Js Engines won't allow us go beyond 9999 recursions and there are 255168 possible combinations in the tic tac toe game. So we expect our computer to get all possible combinations and make an informed choice and do this all the time, which would take a lot of time, if the JS engine don't stop you first. So let's assume some initial positions for it at first and some special cases, so as to limit the times it uses the algorithm and combinations number.
So the first if statement indicates a random pick between an array of 0,2,6,8 which are the corner boxes if its the first to play that is no boxes are filled yet, it is known that the best first move to play is a corner box. Next time it's its turn it checks if the middle box 4 is filled, this will give it a ton of opportunities to move and win. Then the next is to check if you have an opportunity to win and block it or if it has a chance to win go for it with the kick function shown below. This do not need an algorithm to do this.
kick(){
let board=p.map((j,ind)=>state[ind])
for (let i=0;i<wincombos.length;i++){
if((wincombos[i].filter(l=>board[l]==state.player2).length==2 || wincombos[i].filter(n=>board[n]==state.player1).length==2) && wincombos[i].filter(p=>board[p]=='').length>0){
return wincombos[i].filter(pp=>board[pp]=='')[0]
}
}
return false
}
The kick function checks every wincombos array if two of it is already included in player1 or player 2 and return the remaining number then that will be the number it plays either as a block or a win. Then if all these conditions are met in the ai function, it resolves to minimax algorithm.
score(board, player,depth) {
if (
(board[0] == player && board[1] == player && board[2] == player) ||
(board[3] == player && board[4] == player && board[5] == player) ||
(board[6] == player && board[7] == player && board[8] == player) ||
(board[0] == player && board[3] == player && board[6] == player) ||
(board[1] == player && board[4] == player && board[7] == player) ||
(board[2] == player && board[5] == player && board[8] == player) ||
(board[0] == player && board[4] == player && board[8] == player) ||
(board[2] == player && board[4] == player && board[6] == player)
)
{
if(player=state.player2) {
return 10-depth;
}
else{
return -10+depth;
}
}
else if(board.filter(i=>i=='').length==0 ){return 0}
else{return null}
}
minimax(e,nboard,arr,depth,m){
let max=state.player2
let min=state.player1
if(this.score(nboard,m,depth)!==null){return {score :this.score(nboard,m,depth)}}
else{
depth+=1
let moves=[]
let seed=[]
for(let i=0;i<nboard.length;i++){if(nboard[i]==''){seed.push(i)}}
for (let ii=0;ii<seed.length;ii++){
let mboard=this.copy(nboard)
var move={}
move.index=seed[ii]
mboard[seed[ii]]=m
if (m==max){
var res=this.minimax(e,mboard,arr,depth,min)
move.score=res.score
}
else{
var res=this.minimax(e,mboard,arr,depth,max)
move.score=res.score
}
seed[ii]=move.index;
moves.push(move)
}
var best
if(m==min){
var bestsc= -100000
for(var k = 0; k < moves.length; k++){
if(moves[k].score > bestsc){
bestsc = moves[k].score;
best = k;
}
}
}
else{
var bestScore2 = 10000;
for(var l = 0; l < moves.length; l++){
if(moves[l].score < bestScore2){
bestScore2 = moves[l].score;
best = l;
}
}
}
return moves[best];
}
}
copy(board){
return [...board]
}
There are two main functions shown above the first one score checks if there is a win in either way and score the algorithm with either a positive for win or negative number for lose and 0 for draw or null if there is no win or loss or draw. So the minmax function first defines the max variable as player 2 which is computer, min as player1, then check if there is a score or not, if there is returns the score as an object.
Else it loops through the nboard that has been given as an argument of the function to get all remaining blanks that is unfilled spaces as seed and have been defined earlier. Then we loop through the seed, that is remaining blanks and copy the initial nboard to avoid mutation, then add an X or O to the seed and records the index in move. So if m which was added to the seed was the same as the choice of computer, the we do a recursion this time with m as the player1's choice. Then record the score gotten finally as score of passing that specific route. if not then the reverse is the case.
These recursions could lead to further recursions on and on until the total scores are finalized and start coming together. The move object is then added to an array with other moves and their scores which have a total max length of 9. Then we find the maximum of the scores if m is the computer's choice or minimum of the scores if reverse is the case, then return the best move object and this is collected by the ai which will use the index to match the box which will of course be unfilled and play there.
Finally,
function update2(no) {
this.setState({st:no })
}
class Main extends React.Component {
constructor(props){
super(props);
this.state={
st:0
}
update2=update2.bind(this);
this.two=this.two.bind(this)
}
two(){
state={
0:'',1:'',2:'',3:'',4:'',
5:'',6:'',7:'',8:'',play:true,
player1:'',player2:'',text:'',
comp:'',score1:0,score2:0,type:true,
array1:[],array2:[],array:[[],[]]
}
update2(0)
}
render(){
if(this.state.st==0){
var one={display:'block'}
var two={display: 'none'}
var three={display :'none'}
}
else if(this.state.st==1){
var two={display:'block'}
var one={display: 'none'}
var three={display :'none'}
}
else{
var three={display:'block'}
var two={display: 'none'}
var one={display :'none'}
}
return(
<div className='root'>
<div className='reset' onClick={this.two}> Reset All</div>
<div className='comp'>
<div style={one}><Main1 /></div>
<div style={two}><Main2 /></div>
<div style={three}><Main3 /></div>
</div>
</div>
)
}
}
ReactDOM.render(<Main />, document.getElementById('root'));
So this is the main component that hold all other components , we recall the update2 function needed to change its state, we can see it binded to it. I used the display method to switch components, because of method I'm using the components to change other components and the main functions, if they don't render, the state will try to be changed and error will occur. Another way to do it is to use props and parentCallback or React.Context. Then it is rendered to the DOM.
There are many different approach to use and even minimax algorithm has a better and more efficient way to do. You can read more on minimax algorithm here or here. The tic tac toe game we just built isn't unbeatable but covers so many possibilities. So I think it's not bad. Then we can style as we want.
Top comments (0)