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:
- Information: Showing who wins. And better show also whose turn.
- Body: The 9 boxes for users to input O or X.
- 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];
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;
}
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>
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],
];
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[]>([]);
Flow of the program:
- Clicking one of the 9 buttons
- Insert clicked box number to corresponding array
- 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));
const noughtResult: number[][] = winArrays.filter(
(winArray) => isSubset(noughtsArr, winArray)
);
const crossResult: number[][] = winArrays.filter(
(winArray) => isSubset(crossesArr, winArray)
);
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>
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:
- All 9 boxes clicked and X win
- 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]);
Nought and Crosses:
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]);
Top comments (0)