Skip to main content
voidst.one

Tic-Tac-Toe with React, Redux, Rust - Part 2

Game Page

With the main menu page completed, we can now move on to the game page.

Board UI

Let's begin by building the game board, which consists of 9 squares arranged in a 3x3 grid. Sounds pretty simple but there are different ways to implement it.

The most basic approach is to hardcode the elements in HTML, as demonstrated in the official React Tutorial (reproduced below). While this might be fine for simple cases, it doesn’t scale well for larger grids which can be tedious and error-prone.

<div className="board-row">
  <Square />
  <Square />
  <Square />
</div>
<div className="board-row">
  <Square />
  <Square />
  <Square />
</div>
<div className="board-row">
  <Square />
  <Square />
  <Square />
</div>

So how can we make it better? Perhaps we could use a nested for loop in JSX to generate the grid dynamically, but is there a better and cleaner way that requires less code?

A cleaner solution is to use CSS Grid Layout, and with Tailwind CSS, it becomes even simpler and more intuitive. Simply specify the number of columns using grid-cols-<number>, and, if you wish, adjust the spacing between each square with gap-<number>.

Below are the code snippets along with a screenshot of the resulting board:

Screenshot of the board using grid layout

const NUMBER_OF_CELLS = 3 * 3

<div className="grid grid-cols-3 gap-2">
  {Array.from({ length: NUMBER_OF_CELLS }).map((_, i) => (
    <Square key={i} id={i} />
  ))}
</div>
import type { JSX } from "react"

export const Square = ({ id }: { id: number }): JSX.Element => {
  const getSymbol = (): string => {
    return id.toString()
  }

  return <button className="size-16 bg-gray-200">{getSymbol()}</button>
}

Data representation

Things are slowly taking shape. Now, let's shift our focus to refining the game state and logic, and explore ways to manage them more efficiently.

The most common way to store and represent the Tic-Tac-Toe board is to use a one-dimensional array with nine elements (character or integer), where each element represents a square on the 3x3 grid. This approach is straightforward and easy to follow, with no significant disadvantages for data of this scale.

But let’s be a bit more adventurous and try a different approach. Instead of using a character or integer as the basic unit of information, we can use a bit (short for binary digit) — the smallest unit of digital data in a computer. This allows for a much more compact and efficient representation.

Bitboards

By representing each square as a single bit within an integer, bitboards enable highly efficient and compact manipulation of game states using fast bitwise operations. This approach has become standard in many computer implementations of board games (Chess, Checkers, Connect Four, etc) due to its performance advantages.

Each square in Tic-Tac-Toe can be in one of three states: empty, X, or O. However, a single bit can represent only two possible states: 0 or 1.

Therefore, to accurately represent all possible states on the board, we will need two separate bitboards: one to track X’s moves and another to track O’s moves.

Since each bitboard is 9 bits long, they fit comfortably within JavaScript's 64-bit number type. The updated slice state is shown below:

export type TicTacToeSliceState = {
  gameStatus: GameStatus
  players: Player[]
  bitboards: number[]
}

const initialState: TicTacToeSliceState = {
  gameStatus: GameStatus.MainMenu,
  players: [],
  bitboards: [0, 0],
}

While dealing with a number system designed for computers might feel foreign and intimidating, I assure you it’s much simpler than it appears. You don’t need to master binary counting or complex conversions — all you need is just a basic understanding of bitwise operations.

Let's also add a new selector that returns the status of a given square position. To do so, we just have to iterate through the bitboards and determine whether the specified position is occupied by any player. If the position is set in a player's bitboard, we will return the corresponding player id; otherwise, we will indicate that the square is empty by returning null.

selectSquare: (state, position: number) => {
  for (let i = 0; i < state.bitboards.length; i++) {
    if (getBit(state.bitboards[i], position)) {
      return i
    }
  }
  return null
},

...

export const { selectGameStatus, selectSquare } = ticTacToeSlice.selectors

Looks pretty straightforward, but how does getBit work?

export function getBit(a: number, position: number): boolean {
  return ((a >> position) & 1) === 1
}

The right shift operator >>, shifts the bitboard to the right by the given position value.

Suppose a=0b000111000 and position=4, a>>4 shifts the bitboard right by 4 resulting in 0b000000011, as illustrated below:

8 7 6 5 4 3 2 1 0
Before 0 0 0 1 1 1 0 0 0
After 0 0 0 0 0 0 0 1 1

In plain English, the right shift moves the bit in position 4 to position 0 (the least significant bit) by discarding all bits to its right.

Next, the bitwise AND operator is applied with a mask of 1 to filter out all other bits, keeping only the bit now at position 0. Here’s a visual breakdown of the operation 0b000000011 & 1:

8 7 6 5 4 3 2 1 0
Operand A 0 0 0 0 0 0 0 1 1
Operand B 0 0 0 0 0 0 0 0 1
Result 0 0 0 0 0 0 0 0 1

Operand B, also known as the bitmask, can alternatively be left-shifted instead of shifting operand A. This achieves the same result and may make the bitwise operation more intuitive, as the bitmask directly selects the bit of interest

If you’re not familiar with the AND operator, you can refer to the truth table below for clarification. Essentially, the AND operation only returns 1 when both inputs are 1, making it useful for masking specific bits.

A B A AND B
0 0 0
0 1 0
1 0 0
1 1 1

Finally, the result is returned as a boolean by checking if it is equal to 1.

Hopefully, everything is crystal clear now! But if it still feels confusing or overwhelming, don’t worry — you can simply use the function as a “black box” without needing to understand all the details.

Testing the utility function

If you trust me, we can simply move on. However, if you want to be sure the function works as intended, we can take a trust test-driven approach and write some test code to ensure the output matches what we expect.

The project comes pre-configured with Vitest and includes sample test code. To verify everything is working, simply run npm test in your terminal. Review the output to ensure the tests pass before adding new tests.

My getBit function resides in the bit-utils.js file, so I created a corresponding test file named bit-utils.test.js for the test code. Here is an example of how the test code might look:

import { expect } from 'vitest'
import { getBit } from './bit-utils.js'

describe('getBit', () => {
  it('returns true when the specified bit is set (1)', () => {
    expect(getBit(0b101010101, 0)).toBe(true);   // Least significant bit is 1
    expect(getBit(0b101010101, 2)).toBe(true);   // 3rd bit from right is 1
    expect(getBit(0b101010101, 4)).toBe(true);   // 5th bit from right is 1
    expect(getBit(0b101010101, 6)).toBe(true);   // 7th bit from right is 1
    expect(getBit(0b101010101, 8)).toBe(true);   // 9th bit from right is 1
  });

  it('returns false when the specified bit is not set (0)', () => {
    expect(getBit(0b101010101, 1)).toBe(false);  // 2nd bit from right is 0
    expect(getBit(0b101010101, 3)).toBe(false);  // 4th bit from right is 0
    expect(getBit(0b101010101, 5)).toBe(false);  // 6th bit from right is 0
    expect(getBit(0b101010101, 7)).toBe(false);  // 8th bit from right is 0
  });

  it('returns false for out-of-range positions', () => {
    expect(getBit(0b101, 10)).toBe(false); // Position exceeds bit length
    expect(getBit(0, 0)).toBe(false);      // All bits are 0
  });
});

Displaying players symbols on the board

With the selectSquare function now working as intended, we can move on to update the UI so that each square displays the correct player symbol.

To keep the game state and UI separate, I store the player IDs in the board data, then map it to the corresponding symbols when rendering the UI. This approach keeps the data model clean and makes the rendering logic more flexible.

The updated getSymbol function from Square.tsx is shown below:

  const getSymbol = (): string => {
    if (square === 0) {
      return "X"
    } else if (square === 1) {
      return "O"
    }
    return ""
  }

Since the board is still static, we have to modify the initial bitboards values to verify that the squares are rendered correctly. E.g.

bitboards: [0b111000000, 0b000000111],

Handling clicks on the board

To enable players to interact with the board, we need to add a click event handler to the squares.

I am using two parameters in the playMove action: one for specifying the square position, and another to indicate whether the move is being made by the player or the bot.

This additional information allows me to validate each move and ensure that human players are only able to play during their own turn, preventing any moves while it’s the bot’s turn. The new code for Square.tsx is shown below:

const dispatch = useAppDispatch()
function handleClick(): void {
  dispatch(playMove({ position: id, playerType: PlayerType.Human }))
}

return (
  <button className="size-16 bg-gray-200" onClick={handleClick}>
  ...
  </button>
)

Next, let's introduce a new variable turnNumber to the slice state which helps us keep track of the active player. The first player will always take their turn on even-numbered turns (0, 2, 4, etc), while the second player will play on odd-numbered turns (1, 3, 5, etc).

import * as gameLogic from "./gameLogic"

export type TicTacToeSliceState = {
  ...
  turnNumber: number
}

const initialState: TicTacToeSliceState = {
  ...
  turnNumber: 0,
}

export const ticTacToeSlice = createAppSlice({
  ...
  selectors: {
    ...
    selectActivePlayer: state =>
      state.players[gameLogic.getActivePlayerIdFromTurn(state.turnNumber)],

    selectActivePlayerId: state =>
      gameLogic.getActivePlayerIdFromTurn(state.turnNumber),
  },
})

Finally, we will implement the playMove action, which will perform validation to ensure each move is valid before updating the board state.


type PlayMovePayload = {
  playerType: PlayerType
  position: number
}

export const ticTacToeSlice = createAppSlice({
  ...
  reducers: create => ({
    ...
    playMove: create.reducer(
      (state, action: PayloadAction<PlayMovePayload>) => {
        // Player should only be able to make a move when the game is started and ongoing
        if (state.gameStatus !== GameStatus.GameStarted) {
          return
        }

        // Destructure payload
        const {
          playerType,
          position,
        }: { playerType: PlayerType; position: number } = action.payload

        // Get active player id
        const activePlayerId = gameLogic.getActivePlayerIdFromTurn(
          state.turnNumber,
        )

        // Ensure the player type passed in matches the active player's player type
        // Human cannot play on bot's turn
        if (playerType !== state.players[activePlayerId].playerType) {
          return
        }

        // Ensure move is valid
        if (gameLogic.isValidMove(state.bitboards, position)) {
          // Play move and update player bitboard
          state.bitboards[activePlayerId] = gameLogic.playMove(
            state.bitboards[activePlayerId],
            position,
          )
          state.turnNumber++
        }
      },
    ),
    ...
  }),
})

I have also refactored the code by moving the game logic into a separate gameLogic.ts file for better organisation and maintainability.

import { getBit, setBit } from "@/utils/bit-utils"

export const NUMBER_OF_PLAYERS = 2
export const NUMBER_OF_SQUARES = 3 * 3

export function getActivePlayerIdFromTurn(turnNumber: number): number {
  return turnNumber % NUMBER_OF_PLAYERS
}

// returns player id if square is occupied, else return null
export function getSquare(
  bitboards: number[],
  position: number,
): number | null {
  for (let i = 0; i < bitboards.length; i++) {
    if (getBit(bitboards[i], position)) {
      return i
    }
  }
  return null
}

// It's a valid move if square is not occupied
export function isValidMove(bitboards: number[], position: number): boolean {
  return getSquare(bitboards, position) === null
}

// Return a new bitboard with the move played
export function playMove(bitboard: number, position: number): number {
  return setBit(bitboard, position)
}

If you prefer an object-oriented approach, you can define classes for the bitboard and game board instead. I am just organising related functions into separate files to keep things simple, but you can easily refactor the code to better suit your preferred structure.

Checking for game end condition

We are almost finished building a basic, functional game. The final step is to implement checks for end game conditions to determine when the game is over.

Let's add the logic to detect when a player has won, or when the game ends in a draw, and also a variable to record the winner.

export type TicTacToeSliceState = {
  ...
  winner: gameLogic.Winner
}

const initialState: TicTacToeSliceState = {
  ...
  winner: gameLogic.Winner.None,
}

export const ticTacToeSlice = createAppSlice({
  ...
  reducers: create => ({
    ...
    playMove: create.reducer(
      (state, action: PayloadAction<PlayMovePayload>) => {
        ...
        if (gameLogic.isValidMove(state.bitboards, position)) {
          ...
          // Check for game end
          state.winner = gameLogic.getWinner(state.bitboards, activePlayerId)
          if (state.winner === gameLogic.Winner.None) {
            state.turnNumber++
          } else {
            state.gameStatus = GameStatus.GameEnded
          }
        }
      },
    ),
  }),
  selectors: {
    ...
    selectWinner: state => state.winner,
  },
})

export const {
  ...
  selectWinner,
} = ticTacToeSlice.selectors

Here's how the game end logic is implemented with bitboard. The same masking technique we used earlier is applied here.

export enum Winner {
  None = -1,
  Player1 = 0,
  Player2 = 1,
  Draw = 2,
}

const WINNING_LINES = [
  0b111000000, // Row 1
  0b000111000, // Row 2
  0b000000111, // Row 3
  0b100100100, // Col 1
  0b010010010, // Col 2
  0b001001001, // Col 3
  0b100010001, // Diagonal
  0b001010100, // Anti-diagonal
]

const BOARD_FULL_MASK = 0b111111111

// Return the winning lines if found, else return empty array
function getWinningLines(bitboard: number): number[] {
  const lines: number[] = []
  for (const mask of WINNING_LINES) {
    if ((bitboard & mask) === mask) {
      lines.push(mask)
    }
  }
  return lines
}

function isBoardFull(bitboards: number[]): boolean {
  const mergedBitboard: number = mergeWithBitwiseOr(bitboards)
  return (mergedBitboard & BOARD_FULL_MASK) === BOARD_FULL_MASK
}

export function getWinner(bitboards: number[], activePlayerId: number): Winner {
  const winningLines: number[] = getWinningLines(bitboards[activePlayerId])

  if (winningLines.length > 0) {
    return activePlayerId
  } else if (isBoardFull(bitboards)) {
    return Winner.Draw
  } else {
    return Winner.None
  }
}

I have also added a mergeWithBitwiseOr function that uses the reduce method to combine all bitboards into a single one by applying the bitwise OR operator.

While this merging could also be done with a standard for loop, I chose to use reduce to keep the code concise.

export function mergeWithBitwiseOr(numbers: number[]): number {
  if (numbers.length === 0) {
    throw new Error("Array must contain at least one number.")
  }
  return numbers.reduce((acc, curr) => acc | curr)
}

Let’s also update the UI to display the turn order and results.

import { useAppDispatch, useAppSelector } from "@/app/hooks"
import { NUMBER_OF_SQUARES, Winner } from "../gameLogic"
import { exitGame, selectActivePlayerId, selectWinner } from "../ticTacToeSlice"

export const GamePage = (): JSX.Element => {
  const winner: Winner = useAppSelector(state => selectWinner(state))
  const activePlayerId: number = useAppSelector(state =>
    selectActivePlayerId(state),
  )

  function getTurnText(): string {
    if (winner === Winner.None) {
      return `Player ${String(activePlayerId + 1)}'s Turn`
    }
    return ""
  }

  function getResultText(): string {
    switch (winner) {
      case Winner.Player1:
        return "Player 1 (O) Wins"
      case Winner.Player2:
        return "Player 2 (X) Wins"
      case Winner.Draw:
        return "Draw"
      case Winner.None:
        return ""
    }
  }

  return (
    <div className="flex flex-col items-center justify-center min-h-svh">
      <div className="py-2 text-gray-800">{getTurnText()}</div>
      <div className="py-2 text-gray-800">{getResultText()}</div>
      ...
    </div>
  )
}

The winning lines data can be used to visually highlight the winning squares in the UI. You could try it as an additional exercise.

Great! We now have a fully functional two-player Tic Tac Toe game. Next, we can start exploring how to implement a bot opponent.

I will wrap up here for now and cover the next steps in the upcoming post. Stay tuned!