Tic Tac Toe EP 1: UI/UX
This is a 3 parts serious we will cover from smooth UI/UX, undo step, different ways to build AI, from easy to unbeatable. Complete source code at GitHub, if it help you place a stars.
Introduction
In part 1, we will focuse on creating a smooth and intutive UI/UX with React & Styled Componets, Game Logic with Redux, to create a maintainable and highly reuseable Player vs Player Tic Tac Toe.
Final Product in Part 1
UI/UX
To simplfy the problem, we will need:
-
Gameboard
- 3x3 grid for with pawn border
-
Cell
- while player is
onClick
to the cell,X
orO
will shown
- while player is
-
Game Logic
- Use
redux
to handle all the game logic
- Use
Coding time
Game board
We will use table for create 3x3 grid, and use styled components to add css style.
import styled from "styled-components"
const Board = styled.table`
animation: ${clipMask} 1s linear;
border-collapse: collapse;
position: relative;
`
const Row = styled.tr`
&:first-child td {
border-top: 0;
}
&:last-child td {
border-bottom: 0;
}
& td:first-child {
border-left: 0;
}
& td:last-child {
border-right: 0;
}
`
function GameBoard() {
return (
<Board>
<tbody>
{[...Array(3)].map((_, rowIndex) => (
<Row key={rowIndex}>
{[...Array(3)].map((_, colIndex) => {
const cellIndex = rowIndex * 3 + colIndex
return <div>{/* Cell */}</div>
})}
</Row>
))}
</tbody>
</Board>
)
}
export default GameBoard
Cell
Cell while player is onClick
to the cell, X
or O
will shown
import styled from "styled-components"
import Circle from "./Circle"
import Cross from "./Cross"
type CellProps = {
cellIndex: number
value: Marks | null
}
const StyledCell = styled.td`
border: 6px solid var(--background-color-darker);
height: 125px;
width: 125px;
text-align: center;
vertical-align: middle;
padding: 15px;
cursor: pointer;
padding: 0.3em 0.3em;
`
function Cell({ cellIndex, value }: CellProps) {
const onClickHandler = (index: number) => {
if (value !== null) {
// only empty cell allow to place new mark
return
}
console.log(`placed a mark at ${index}`)
}
return (
<StyledCell onClick={() => onClickHandler(cellIndex)}>
{value === null ? " " : value === "X" ? <Cross /> : <Circle />}
</StyledCell>
)
}
export default Cell
Marks
We also need to prepare the X
and the O
.
// Cross.tsx
import styled, { keyframes } from "styled-components"
type Props = { width?: number; height?: number }
const crossAnimation = keyframes`
to {
stroke-dashoffset: 0;
}
`
const StyledCross = styled.svg`
fill: none;
stroke: var(--cross-color);
stroke-width: 8px;
stroke-linecap: round;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
transform: rotate(90deg);
& g line:nth-child(1) {
animation: ${crossAnimation} 0.3s ease-in forwards;
}
& g line:nth-child(2) {
animation: ${crossAnimation} 400ms ease-out 200ms forwards;
}
`
function Cross({ width = 88, height = 88 }: Props) {
return (
<StyledCross
version="1.1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width={`${width}px`}
height={`${height}px`}
viewBox="0 0 88 88"
>
<g>
<line className="cross" x1="3.18" y1="3.18" x2="83.18" y2="83.18" />
<line className="cross" x1="3.18" y1="83.18" x2="83.18" y2="3.18" />
</g>
</StyledCross>
)
}
export default Cross
// Circle.tsx
import styled, { keyframes } from "styled-components"
type CircleProps = { width?: number; height?: number }
const circleAnimation = keyframes`
to {
stroke-dashoffset: 0;
}
`
const StyledCircleSVG = styled.svg`
fill: none;
stroke: var(--circle-color);
stroke-width: 7px;
stroke-linecap: round;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
transform: scaleX(-1) rotate(-90deg);
animation-iteration-count: 1;
animation: ${circleAnimation} 0.5s ease-in forwards;
`
function Circle({ width = 88, height = 88 }: CircleProps) {
return (
<StyledCircleSVG
version="1.1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width={`${width}px`}
height={`${height}px`}
viewBox="0 0 88 88"
>
<circle cx="44" cy="44" r="40" />
</StyledCircleSVG>
)
}
export default Circle
Game Flowchart
we are going to preare the reducers based on the game flowchart.
- editBoard(mark: "X"|"O", cellIndex: number)
- check winner
- nextPlayer
- restartNewGame
First we create a slice ticTacToeSlice
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
import _ from "lodash"
import type { AppThunk, RootState } from "../../app/store"
export type LineType = "horizontal" | "vertical" | "diagonal"
export type Cell = Marks | null
export type Marks = "X" | "O"
export type GameResult =
| { winner: Marks; lineType: LineType; linePosition: number }
| "TIE"
| null
export interface GameState {
board: Cell[]
currentRound: Marks
result: GameResult
}
// Define the initial state using that type
const initialState: GameState = {
board: Array(9).fill(null),
currentRound: "X",
result: null,
}
export const ticTacToeSlice = createSlice({
name: "ticTacToe",
initialState,
reducers: {
// 1. editBoard(mark: "X"|"O", cellIndex: number)
// 2. check winner
// 3. nextPlayer
// 4. restartNewGame
},
})
export const { editBoard, nextPlayer, checkWinner, newGame } =
ticTacToeSlice.actions
export const ticTacToeSlice = createSlice({
name: "ticTacToe",
initialState,
reducers: {
initialGame: (state) => {
state.board = initialState.board
state.currentRound = initialState.currentRound
state.result = initialState.result
},
newGame: (state) => {
state.board = initialState.board
state.currentRound = initialState.currentRound
state.result = initialState.result
},
editBoard: (
state,
{ payload }: PayloadAction<{ player: Marks; cellIndex: number }>
) => {
if (state.board[payload.cellIndex] !== null) return
state.board = state.board.map((cell, index) =>
index === payload.cellIndex ? payload.player : cell
)
},
nextPlayer: (state) => {
state.currentRound = state.currentRound === "X" ? "O" : "X"
},
checkWinner: (state) => {
state.result = checkForWinner(state.board)
},
},
})
CheckWinner
check winner for the tic tac toe is pretty easy, since there is only 8 Form
of win combination.
So we will first define those combination as winCombination
. We will iterate all the combination,
to check is there any match from the gameboard. if true
: we find the winner, if no empty cell
: "Tie",
otherwise, no winner is found.
// ticTacToe Reducers ...
const winCombination = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]
function checkForWinner(board: Cell[]): GameResult {
for (let i = 0; i < winCombination.length; i++) {
const [a, b, c] = winCombination[i]
if (board[a] !== null && board[a] === board[b] && board[a] === board[c]) {
return {
winner: board[a] as Marks,
...caclDrawingPosition(i, winCombination[i]),
}
}
}
if (!board.includes(null)) return "TIE" // 0 indicates a draw
return null
}
Draw the winning line
Once we found that there is a winner, we daaw the line.
The First 3 combination in winCombination [0, 1, 2]
, [3, 4, 5]
, [6, 7, 8]
are horizontal
.
The 4-6 combination in winCombination [0, 3, 6]
,[1, 4, 7]
,[2, 5, 8]
are vertical
.
The last 2 combination in winCombination [0, 4, 8]
,[2, 4, 6]
are diagonal
So we First define a type of line horizontal
| vertical
| diagonal
.
const caclDrawingPosition = (
i: number,
combine: number[]
): { lineType: LineType; linePosition: number } => {
const [a] = combine
if (i < 3) {
return {
lineType: "horizontal",
linePosition: Math.floor(a / 3) + 1,
}
} else if (i < 6) {
return {
lineType: "vertical",
linePosition: (a % 3) + 1,
}
} else {
return {
lineType: "diagonal",
linePosition: i === 6 ? 1 : 2,
}
}
}
Final Product
Project Files
For more information about how to use the plugin and the features it includes, GitHub Repository for Part 1.
Ready to Lesson 2
In Lesson 2, we are going to create a Tic tac toe A.I. from easy
-> medium
-> Unbeatable
.
And we also will talk about the algorithm behine it.
Tic-Tac-Toe-Lesson 2