How to Write an Unbeatable Tic-Tac-Toe in JavaScript
Writing small puzzle games is a nice way of improving your programming skills as those games tend to present you with challenge after challenge and force you to really put tremendous thought into writing your code.
One such game is the classic Tic-Tac-Toe.
In this little walk-through (read long and descriptive) about writing an unbeatable tic-tac-toe, I will be using Vanilla JavaScript for logic and HTML/CSS for UI. I won’t be concentrating much on UI part of it, because, duh!
Another little disclaimer – we will be concentrating on logic primarily and code will come second to that. Again, if you want to skip the “walk-through” and jump right to the code, I have a public repository on GitHub at https://github.com/FaisalST32/tic-tac-toe. I also have a live version of the game available at https://games.faisalrashid.online/tictactoe/
So, let’s dive in.
First of all you need to create the UI. Just use simple HTML to create 9 input boxes, or divs even to hold your X’s and O’s. The id’s of these input boxes are important as they will be our reference to the X’s and O’s. To keep it consistent with the positioning of our boxes, I have given the input boxes the id’s “0-0” to “2-2”. To visualize:
0-0 | 0-1 | 0-2 |
1-0 | 1-1 | 1-2 |
2-0 | 2-1 | 2-2 |
This will give us an easy to use 3×3 matrix. You can design your UI whatever way you want. It should simply employ this form of queryable HTML elements.
Here’s a screenshot of my UI. Simple boxes with a little gloss of CSS.

Now, the logic! First of all, whenever a user, or a participant in this case, clicks or taps an empty box, the value of that box should change to either an X or an O, depending upon whose turn it is.
Implementing that is simple. I use a global variable to hold the ‘currentPlayer‘. This variable has a value of either X or O. Then I simply write a method to toggle current player, whenever a box is tapped. I call the method changePlayer.
All this is started by tapping a box. So, we need a function to get us started. We write a function that marks the box with X or O and changes player. We call the function onCheckBox. So far, the function checks a box and and then changes player.
Now, while the boxes are being tapped, we need to keep track of the checked boxes. Using an array should help us get this job done. Simply holding the id’s of all the boxes is useful, but it’s not enough. We will definitely need to hold the id’s (co-ordinates in this case) as well as the player who checked the box. So, our checkedBoxes array will be an array of objects of this form:
[{box: ‘0-1’, player: ‘X’}, {box: ‘2-1’, player: ‘O’}, …]
We now have two global variables – one to hold currentPlayer and other to hold checkedBoxes.
Another thing that I like to keep track of is the Turn Count. I can get that from the chekedBoxes array, but this way it’s simpler and very useful if I’m manipulating the checkedBoxes array.
Since we will be writing a Player vs CPU mode only (github repo contains both 1p and 2p modes), these are all the global variables that we are going to need – checkedBoxes, currentPlayer and turnCount.
NOTE: It’s always good practice to keep your global variables at a minimum to avoid dependencies and unexpected behavior within functions.
I believe our onCheckBox method is now ready. This is what it looks like
function onCheckBox(element) { checkedBoxes.push({ box: element.id, player: currentPlayer }); checkElement(element); turnCount++; var gameStatus =checkWinner(); //Will come to this method in a bit switchPlayer(); }function checkElement(element){ element.value = currentPlayer; element.disabled ="disabled"; }function switchPlayer() { currentPlayer = currentPlayer =="X"?"O":"X"; document.querySelector('.current-player').textContent = currentPlayer; }
We are passing the clicked Html element as one of the parameters to the method to access it’s id for coordinates. Everytime we check a box we need to check whether there is a winner or whether the game is drawn.
This brings us to our the checkWinner method. This method simply checks whether the X’s or O’s are in a single line (rules of tic-tac-toe) to determine whether there is a winner. It’s not very difficult to build that logic. We query the checkedBoxes array and find all the coordinates with player X. If the coordinates follow the pattern (a-0, a-1, a-2) or (0-a, 1-a, 2-a) where a = 0 or 1 or 2, then we have a horizontal or vertical line checked. Well, diagonal lines are a little tricky. For right to left diagonal, we use (a-a, b-b, c-c) and for left to right diagonal we use (0-2, 1-1, 2-0). If we find any of these patterns in the checkedBox matrix then the ‘player’ having the pattern is the winner. If none of these patterns match, then we simply check whether one of the two players has checked 5 boxes to determine that it’s a draw.
function checkWinner(isCheckOnly=false) { if (currentPlayer =="X") { //Get all the boxes marked by Player X var xs = checkedBoxes.filter(item=> item.player =="X").map(value=> { return { x: Number(value.box.split("-")[0]), y: Number(value.box.split("-")[1]) } }); returncalculateScore(xs); } else if (currentPlayer =="O") { //Get all the boxes marked by Player O var os = checkedBoxes.filter(item=> item.player =="O").map(value=> { return { x: Number(value.box.split("-")[0]), y: Number(value.box.split("-")[1]) } }); returncalculateScore(os); } }function calculateScore(positions) { //Check right diagonalif (positions.filter(i=> { return i.x == i.y }).length ==3) { return'game over'; }//Check Left diagonal if (positions.filter(i=> { return (i.x ==0&& i.y ==2) || (i.x ==1&& i.y ==1) || (i.x ==2&& i.y ==0) }).length ==3) { return'game over'; } //check horizontal match for (var i =0; i <3; i++) { var consecutiveHorizontal = positions.filter(p=> p.x == i); if (consecutiveHorizontal.length ==3) { return'game over'; } //check vertical match var consecutiveVertical = positions.filter(p=> p.y == i); if (consecutiveVertical.length ==3) { return'game over'; } } //check draw if (positions.length ==5) { return'game drawn'; } //if none of the conditions match 'game on' return'game on'; }
Here I’m delegating the responsibility of finding result to calculateScore method. These two methods are essentially translating what we said above to JavaScript code. Nothing fancy here.
Let’s try something now. Shall we? I believe our game is now ready to handle all the tic-tac-toe logic. What remains is making it unbeatable. To make the game unbeatable, I want to set some ground rules to avoid making this post excruciatingly long (you’re kidding, right). The CPU will always play second and will be primarily focused on not letting the opponent win. So, our onCheckBoxmethod will see the addition of the method computerPlays for CPU’s turn.
function onCheckBox(element) { checkedBoxes.push({ box: element.id, player: currentPlayer }); checkElement(element); turnCount++; var gameStatus = checkWinner(); switchPlayer(); if (turnCount %2 == 1&& gameStatus == 'game on'){ computerPlays(); } }
As you can see the computerPlays method will only be called if the turnCount is odd, ie, it’s 2nd, 4th, 6th or 8th turn, and if gameStatus returned by the checkWinner method is ‘game on’ as there is no point in continuing the game, if the game is already over. To understand how to counter a human move, I came up with 5 cases. These five cases in the order of their priority are:
- First Move
- Finishing Move that allows CPU to win the game.
- Saving Move that allows CPU to save the game.
- Predicting a move that will trap the CPU and avoiding it.
- And if none of these fit, a Random move.
Here’s a sneak preview of our computerPlays method.
function computerPlays() { var nextBoxCoords; if(turnCount ==1){ nextBoxCoords =computeFirstMove(); } if (!nextBoxCoords){ nextBoxCoords =computeFinishingMove(); } if (!nextBoxCoords) { nextBoxCoords =computeSavingMove(); } if (!nextBoxCoords) nextBoxCoords =predictTrappingMove(); if (!nextBoxCoords) { nextBoxCoords =computeRandomMove(); } var nextBox = document.querySelector(`[id='${nextBoxCoords}']`); onCheckBox(nextBox); }
1. First Move
Computing first move is out of experience, although, I could have written an algorithm for it. But to avoid complexity, I just went with past experience. If it’s CPU’s first move, we need to find where the opponent played. If it’s center box, we return a corner box. Similarly, if he played a corner box, we return an edge box adjacent to it and finally if he played an edge box, we return the center box. By the way, 1-1 is the center box, 0-1, 1-0, 1-2, 2-1 are the edge boxes and, you guessed it, 0-0, 0-2, 2-0, 2-2 are the corner boxes.
Here it is,
function computeFirstMove(){ var playedMove = checkedBoxes.map(b=> b.box)[0]; var edgeMoves = ['0-1', '1-0', '1-2', '2-1']; var cornerMoves = ['0-0', '0-2', '2-0', '2-2']; var centerMove = ['1-1']; if(edgeMoves.find(m=> m == playedMove)) returnedgeMoveResponse(playedMove); else if(cornerMoves.find(m=> m == playedMove)) return'1-1'; else if(centerMove.find(m=> m == playedMove)) return cornerMoves[Math.floor(Math.random()*cornerMoves.length)]; }function edgeMoveResponse(playedMove){ if(playedMove =='1-2') return'0-2'; else if (playedMove =="0-1") return"0-0"; else if (playedMove =="1-0") return"2-0"; else if(playedMove =='2-1') return'2-0'; }
To avoid cramming up the computeFirstMove method, I moved out the edgeMoveResponse method. Now, that we got the first move sorted, let’s move to the next check – computeFinishingMove.
2. Finishing Move that allows CPU to win the game.
If the opponent is there for the taking we would prioritize taking him. Let’s say we have the following situation:

Since, it’s CPU’s turn (O) we would definitely want to check the lower-right box. That’s where our computeFinishingMove method comes in. It simply iterates over all the remaining boxes and checks them one by one. It then calls the checkWinner method on each iteration and if there’s a winner, just returns the box of that iteration as the ‘nextBoxCoords‘ to the computerPlays method. Here’s the code:
function computeFinishingMove() { var remainingMoves =getRemainingMoves(); var finishingMoveCoords; for (var move of remainingMoves) { checkedBoxes.push({ box: move, player: currentPlayer }); var nextBox = document.querySelector(`[id='${move}']`) if (checkWinner() =='game over') { finishingMoveCoords = move; onUncheckBox(nextBox, true); break; } onUncheckBox(nextBox, true); } if(finishingMoveCoords){ console.log('Playing Finishing Move') return finishingMoveCoords; } else{ return''; } }function getRemainingMoves() { var allMoves = ['0-0', '0-1', '0-2', '1-0', '1-1', '1-2', '2-0', '2-1', '2-2',] var playedMoves = checkedBoxes.map(b=> b.box); return allMoves.filter(m=>!playedMoves.find(move=> move == m)); } function onUncheckBox(element, isImplicit=false) { checkedBoxes = checkedBoxes.filter(b=> b.box != element.id); if (!isImplicit) { element.value =''; element.removeAttribute("disabled"); turnCount--; switchPlayer(); } }
While checking a box to check for winner, we have to make sure that we uncheck that box after the iteration completes to avoid bad data in checkedBoxes array. For that matter, we employ the onUncheckBox method above. If there is no ‘finishing move’ available, we check for the next condition – computeSavingMove.
3. Saving Move that allows CPU to save the game.
Consider the following scenario:

Here it’s CPU’s (O) turn to play. Clearly playing anywhere other than top middle box will cause the game to end in the next turn. So, we have to definitely check that box. To do this, we write a method similar to previous method where we this time iterate over all the remaining boxes and during each iteration fill them with opponent’s mark and then call the checkWinner method. If it returns ‘game over’ in any of the iterations then that is our desired box. Here’s what this translates to in JavaScript.
function computeSavingMove() { var remainingMoves =getRemainingMoves(); switchPlayer(); var savingMoveCoords; for (var move of remainingMoves) { checkedBoxes.push({ box: move, player: currentPlayer }); var nextBox = document.querySelector(`[id='${move}']`) if (checkWinner() =='game over') { savingMoveCoords = move; onUncheckBox(nextBox, true); break;} onUncheckBox(nextBox, true);} switchPlayer(); if(savingMoveCoords){ console.log('Playing Saving Move') return savingMoveCoords;}}
You might argue that this is a clear violation of the DRY (Don’t Repeat Yourself) principle. But, sometimes, to keep the code readable and avoid complexities, you may want to take a rain check on the DRY. To make sure that I’m iterating with opponent’s move and note CPU’s, I’m calling the switchPlayer method before iterating over remainingMoves. If none of the remaining moves is a saving move we then turn our attention to the trickiest condition – predictTrappingMove.
4. Predicting a move that will trap the CPU and avoiding it.
A ‘Trapping Move’, as I like to call it is when the opponent checks a box that leaves you with two Finishing boxes to fill, ultimately leading you to lose the game. Here’s a screenshot to show exactly that:

In the image, it’s Player O’s turn and he is in a lose-lose situation. If he check tile 0-0, Player’s X will check 2-2 to win the game and vice versa. So, the only way out of this situation is to avoid it. That’s where our next method, predictTrappingMove comes in.
This method isn’t complicated in terms of execution but the planning needs to be spot on. So, what we do is, every time the opponent plays a move, we check for the possibility of a trapping move, the next time he plays. To achieve this, we will have to one by one check every box with our move. On each turn, we will then have to check each remaining box with the opponents move. Then, we check whether more than one winning move was created as a result of that. I know, I know, what the hell, right? Okay let’s break it down. Suppose we are in this situation:

The opponent is playing for O’s. If I, for instance, play 1-0, the opponent can easily play 0-2 and trap me like this.

Since, there was no finishing or saving move available, I wasn’t able to detect this. To detect this, I will have to check every single box till I find one that’s safe. I can accomplish that by first checking box. Then I will play opponent’s turn on each of the remaining boxes.
This way, I can determine whether any of his moves will result in a trapping move. Any of my turns that I don’t find a trapping move anywhere, will be my next move. For some turns, it won’t be possible for the opponent to play all the available boxes, eg, a turn where he has to play a saving move. In those cases, I won’t be iterating over all his possible moves, but just the saving ones. I’ll be making the assumption that the opponent will try to save himself if I play a move that forces him to do so. Okay, this is how it all translates to JavaScript.
function predictTrappingMove() { var checkedBoxesBackup = checkedBoxes.slice(); var remainingMoves =getRemainingMoves(); var nextMove; var moveFound; for(var move of remainingMoves){ checkedBoxes.push({box: move, player: currentPlayer}) switchPlayer(); //Check if the opponent needs to play a saving move var savingMove = computeSavingMove(); if(savingMove){ checkedBoxes.push({box: savingMove, player: currentPlayer}); if(checkTrap() =='no trap'){ checkedBoxes.pop(); switchPlayer(); nextMove = move; break; } checkedBoxes.pop(); switchPlayer(); continue; } //If no saving move is required, check each position else{ switchPlayer(); for(var opponentMove ofgetRemainingMoves()){ switchPlayer(); moveFound =true; checkedBoxes.push({box: opponentMove, player: currentPlayer}); if(checkTrap() =='trapped'){ moveFound =false; checkedBoxes.pop(); switchPlayer(); break; } checkedBoxes.pop(); switchPlayer(); } } checkedBoxes.pop(); if(moveFound){ nextMove = move; break; } }checkedBoxes = checkedBoxesBackup; return nextMove; } function checkTrap(){ var boxes =getRemainingMoves(); var winningMoveCount =0; for(var freeMove of boxes){ checkedBoxes.push({box: freeMove, player: currentPlayer}); var result = checkWinner(true); if(result =='game over') winningMoveCount++ checkedBoxes.pop(); } if(winningMoveCount >1){ return 'trapped'; } else{ return 'no trap'; } }
Here, predictTrappingMove is first checking a box on my behalf. Then, it’s checking whether the opponent needs to play a saving move to counter that. If yes, it’s calling the checkTrap method to see whether more than one saving conditions were created on that opponent move.
If not, that is the move that will be returned and played. If that’s not the case, however, and the opponent’s saving move created a trap for us, we need to move forward and check the remaining boxes for ourselves, and then in each turn check the remaining boxes on the opponent’s behalf and call the checkTrap method each time. If any of the boxes creates a ‘trapping move’, we avoid that move and keep iterating over remaining moves, till we find a safe move to play.
It may be a little confusing still, but if you go over the code a few times, it should all be clear.
5. Random move
I wrote the computeRandomMove in the first versions of the game to calculate a move if all checks don’t return a move. However, it’s not relevant now as checkTrappingMove will always return a move, irrespective of everything. Here’s the code still if you need it.
function computeRandomMove() { var remainingMoves =getRemainingMoves(); return remainingMoves[Math.floor(Math.random()*remainingMoves.length)]}
It’s simply leveraging the Math.random() method of JavaScript and returning a box based on that from the remainingMoves. So, that’s it. You’re ready to create your unbeatable tic-tac-toe. You can go ahead and write your own with the same principles or even improve over these.
You can even try to write a method that forcesTrappingMove on the opponent making your player a little more aggressive. If you got this far, thanks for your patience. Till next time!
TL;DR Writing code to create an unbeatable tic-tac-toe is simple, but coming up with the logic is what matters.