DEV Community

Cover image for How to make a Noughts and Crosses game in React
Sam
Sam

Posted on • Updated on

How to make a Noughts and Crosses game in React

Photo by Elīna Arāja from Pexels

Introduction

Fun fact: The well known epic mini game "Tic-Tac-Toe" in Britain is called "Noughts and Crosses". While the former one is playing with consonant (T), the later one is playing with the vowel (O).

I am so excited to have it as my first React.js project. The simple game rule is just good for a junior developer to get familiar with handling logic. Let's take a look at UI design first and then the logic.


UI Design

There are 3 main parts:

  1. Information: Showing who wins. And better show also whose turn.
  2. Body: The 9 boxes for users to input O or X.
  3. Button: A "Play Again" button at the end of the game

For the body, I declare a default grid for the 9 boxes:

const defaultGrid = [1, 2, 3, 4, 5, 6, 7, 8, 9];
Enter fullscreen mode Exit fullscreen mode

Then a grid-container is made to contain the 3x3 grid. The gap together with background color do the trick of showing the lines like 井.

.grid-container {
  display: grid;
  grid-template-columns: auto auto auto;
  grid-gap: 15px;
  background-color: #444;
}
Enter fullscreen mode Exit fullscreen mode

Then loop the grid array in JSX.

<div className="grid-container">
  {defaultGrid.map((boxNumber) => (
    <button
      type="button"
      key={boxNumber}
      value={boxNumber}
      onClick={handleClick}
    >
      {boxNumber}
    </button>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

Logic

There should be 3 status for each box:

  • Empty
  • O
  • X

Winning criteria is defined:

const winArrays = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
    [1, 4, 7],
    [2, 5, 8],
    [3, 6, 9],
    [1, 5, 9],
    [3, 5, 7],
  ];
Enter fullscreen mode Exit fullscreen mode

Two array is created to contain a list of box number that has been clicked by each side during the game.

const [noughtsArr, setNoughtsArr] = useState<number[]>([]);
const [crossesArr, setCrossesArr] = useState<number[]>([]);
Enter fullscreen mode Exit fullscreen mode

Flow of the program:

  1. Clicking one of the 9 buttons
  2. Insert clicked box number to corresponding array
  3. Toggle turn

Winner calculation takes place in useEffect(), which keep watching at the states of the Noughts Array and Crosses Array.

I found a function array.every() in ES6 very helpful for the calculation. On MDN website it has provided an example to check if an array is a subset of another array. So my thought is to check each of the possible win array whether it is a subset of Noughts or Crosses clicked numbers or not. E.g. if the X side has clicked box 1,2,6,9, then crossesArr would be [1,2,6,9]. Neither [1, 2, 3] nor [3, 6, 9] in winArrays is a subset of crossesArr so Crosses has not been qualified to win yet.

const isSubset = (xoArr: number[], winArr: number[]) =>
    winArr.every((number) => xoArr.includes(number));
Enter fullscreen mode Exit fullscreen mode
const noughtResult: number[][] = winArrays.filter(
  (winArray) => isSubset(noughtsArr, winArray)
);
const crossResult: number[][] = winArrays.filter(
  (winArray) => isSubset(crossesArr, winArray)
);
Enter fullscreen mode Exit fullscreen mode

filter() will return value that passed isSubset() checking. So the last thing to do is to check the length of noughtResult and crossResult and see which is larger than 0 then that is the winner.


Lesson Learned

Array handling. There is quite a number of arrays to handle and calculate. It is also a good exercise for spread operation.

Functional Programming. Tried applying the concepts of functional programming like immutability and separation of data and functions. And I found Single-responsibility principle(SRP) make the testing much easier.

The code below is showing...

  • two higher order functions are created to get correct box status and render a corresponding icon (X/O) by a given box number.
  • one higher order function to paint the win icon dynamically.
<button
  ...
  style={{ color: getWinBoxStyle(boxNumber) }}
  ...
>
  {withIcon(getStatus(boxNumber))}
</button>
Enter fullscreen mode Exit fullscreen mode

Grid and Flex in CSS. To build a table like layout in a modern way.

Typescript. This is my first typescript project with ESLint and I am getting mad with so many errors in my code to solve! Time spending on solving typescript errors is probably more than coding the program logic itself. After all, it would still only be a small taste of typescript to me as I didn't do all the variable type and check type.

GitHub Pages. Setting up GitHub Pages workflow for CI/CD. It does a list of actions like build, test and deploy every time I push the code.


Thing to think about

Extreme Case handling. Think about 2 extreme cases:

  1. All 9 boxes clicked and X win
  2. All 9 boxes clicked but draw game.

I would not be happy if X win but a "Draw Game!" message is shown. In useEffect() I thought the logic was in sequential order so I tried to put "Handle Draw" after checking winner but it did not work as expected. Below is the code that works fine. I lift "Handle Draw" up to the top so the program can check win before handle draw game as expected. But the order of code goes a bit strange. I'm not sure if anything I missed.
For a quick check, You can try below order of box clicking:
1 2 3 4 5 6 8 9 7 for X win at 9th box.
1 2 3 7 8 9 4 5 6 for draw game.

const [winner, setWinner] = useState('');
...
useEffect(() => {
  // Handle Draw
  const combinedArr = [...crossesArr, ...noughtsArr];
  if (!winner && combinedArr.length === 9) {
    setWinner('Draw');
  }
  // Check who is eligible to win
  const noughtResult: number[][] = winArrays.filter(
    (winArray) => isSubset(noughtsArr, winArray)
  );
  const crossResult: number[][] = winArrays.filter(
    (winArray) => isSubset(crossesArr, winArray)
  );

  // Setting Winner
  if (noughtResult.length > 0) {
    setWinner('Noughts');
    const result = [...noughtResult];
    setWinResult(result);
  } else if (crossResult.length > 0) {
    setWinner('Crosses');
    const result = [...crossResult];
    setWinResult(result);
  }
}, [noughtsArr, crossesArr]);
Enter fullscreen mode Exit fullscreen mode

Nought and Crosses:

Github
Live site


2022-02-27 Update:
I added a variable thisWinner for "Handle Draw" to refer to. So that the flow would look better and make more sense.

useEffect(() => {
    // Check who is eligible to win
    const noughtResult: number[][] = winArrays.filter((winArray) => isSubset(noughtsArr, winArray));
    const crossResult: number[][] = winArrays.filter((winArray) => isSubset(crossesArr, winArray));

    // Setting Winner
    let thisWinner = '';
    if (noughtResult.length > 0) {
      thisWinner = 'Noughts';
      const result = [...noughtResult];
      setWinResult(result);
    } else if (crossResult.length > 0) {
      thisWinner = 'Crosses';
      const result = [...crossResult];
      setWinResult(result);
    }
    setWinner(thisWinner);

    // Handle Draw
    const combinedArr = [...crossesArr, ...noughtsArr];
    if (!thisWinner && combinedArr.length === 9) {
      setWinner(`Draw`);
    }
  }, [noughtsArr, crossesArr]);
Enter fullscreen mode Exit fullscreen mode

Discussion (0)