August 02, 2020
0 strokes bestowed

Tic-Tac-Toe in React

edit

If video content is more your thing than reading a technical article, you can watch my collection of egghead lessons teaching the material found in this blog post. No matter which you prefer, I hope you enjoy the content.

A few weeks ago, I took some time off from work. I was feeling a little burnt out and needed to rest and have some fun. Sometimes that means not coding at all. Other times, it means coding some silly stuff of no real importance. Turns out, coding is still fun after all! I decided to do the latter on my staycation and code up a bunch of silly games. I'm going to share the simplest one with you here: tic-tac-toe.

Before we get started, I want to say that even though I don't build games for a living (or even really as a hobby), coding up a game from time to time is a great way to learn some new skills and patterns. It's also excellent exercise for your brain and trains you to think algorithmically. In fact, I once was asked to code tic-tac-toe in a job interview, so don't scoff at this post. It might help you land a job some day.

Tic-tac-toe is a very simple game, and because it's so simple, it makes for a good introduction to some of the principles of grid and turn-based games.

Let's start with a top-level Game component that will store the state of our tic-tac-toe game. For now, it's just an empty function that returns null, but it won't be this way for long.

function Game() {
  return null
}

Tic-tac-toe consists of a 3x3 grid on which the players put Xs and Os. We need a way to generate this grid and display the state of the data with a Grid component. We could do this a number of ways, but I'm going to create a 2-dimensional array of values. We will keep it very simple. Those values can only be null, X, or O.

For the sake of those new to 2-dimensional grids, I'm going to break this down piece by piece, or rather index by index. When we create games with a 2-dimensional array, the outermost array is the grid. The grid consists of rows which are also arrays. This is why it's called a 2-dimensional array. You might also come across the term "matrix" for this data structure.

A row consists of values. Each value in the row is a column in our grid. I typically think of each column value as a cell and refer to it as such in my game's program.

When we start a game of tic-tac-toe, our board consists of 3 rows and 3 columns, where every cell is the value of null. So our grid looks like this:

const grid = [
  [null, null, null],
  [null, null, null],
  [null, null, null],
]

For a game like tic-tac-toe, it is simple enough to manually create a starting grid like this. But, as you build more games, you will find that making a function to generate a grid for you will be quite helpful. My generateGrid function looks like this:

function generateGrid(rows, columns, mapper) {
  return Array(rows)
    .fill()
    .map(() =>
      Array(columns)
        .fill()
        .map(mapper)
    )
}

We can make a function specific for a tic-tac-toe game by baking in a few values:

const newTicTacToeGrid = () => generateGrid(3, 3, () => null)

Let's add a newTicTacToeGrid to our Game component so that we'll be able to pass it down eventually into other components.

function Game() {
  // This will eventually be a stateful grid
  const grid = newTicTacToeGrid()

  return null
}

Now that we can easily generate our tic-tac-toe grid, we need to render it. Coincidentally enough, using CSS Grid to display our grid works very well. Here's one of the ways I might approach this with React:

function Grid({ grid }) {
  return (
    // Wrapping the grid with a div of inline-block means that the grid
    // takes up only the space defined by the size of the cells, while
    // still allowing us to use fractional values for the grid-template-*
    // properties
    <div style={{ display: 'inline-block' }}>
      <div
        style={{
          // We set a background color to be revealed as the lines
          // of the board with the `grid-gap` property
          backgroundColor: '#000',
          display: 'grid',
          // Our rows are equal to the length of our grid
          gridTemplateRows: `repeat(${grid.length}, 1fr)`,
          // Our columns are equal to the length of a row
          gridTemplateColumns: `repeat(${grid[0].length}, 1fr)`,
          gridGap: 2,
        }}
      >
        {grid.map((row, rowIdx) =>
          row.map((cell, colIdx) => (
            // We put the colIdx first because that is our X-axis value
            // and the rowIdx second because that is our Y-axis value
            // Getting in the habit makes using 2d grids much easier
            <Cell key={`${colIdx}-${rowIdx}`} cell={cell} />
          ))
        )}
      </div>
    </div>
  )
}

const cellStyle = {
  backgroundColor: '#fff',
  height: 75,
  width: 75,
}

function Cell({ cell }) {
  return <div style={cellStyle}>{cell}</div>
}

The comments in the code block above explain most of my decisions, but just to recap quickly, we use CSS Grid to layout our rows and columns. We can do that by setting the grid-template-rows property equal to the length of our grid array, and making grid-template-columns equal to the length of the first row in our grid.

Since this is tic-tac-toe, we style it by giving the grid a background color and using the grid-gap property to "reveal" the grid lines by making each of our cells an offsetting background color. This is a clever and simple way to avoid having to write various borders for each cell.

Let's also be sure to pass grid from our Game component down into our Grid component.

function Game() {
  const game = newTicTacToeGrid()
  return <Grid grid={grid} />
}

Now when we render Game, it should look like something like this:

Now that we have our grid, we should make our Cells interactive. I'm going to add a button to each cell to make it clickable and accessible. I'm also going to pass a handleClick event handler function into the component to be used with the button's onClick property.

handleClick will be passed down as a prop from our top level Game component through the Grid to get to the Cell. We could also accomplish this with hooks or context, but I don't mind prop-drilling when our UI hierarchy is this small.

function Game() {
  const grid = newTicTacToeGrid()
  const handleClick = () => {}

  return (
    <div style={{ display: 'inline-block' }}>
      <Grid grid={grid} handleClick={handleClick} />
    </div>
  )
}

function Cell({ cell, handleClick }) {
  return (
    <div style={cellStyle}>
      <button type="button" onClick={handleClick}>
        {cell}
      </button>
    </div>
  )
}

Now, what should handleClick do? handleClick should take the coordinates of the cell clicked, and dispatch this action whatever is handling our state to determine the next state. This sounds like a good use case for useReducer. Let's update handleClick to accept x and y arguments and pass them on to some state management. This will add quite a bit of code, but I'll explain it in a bit.

// Simple way to deeply clone an array or object
const clone = x => JSON.parse(JSON.stringify(x))

// An enum for the next turn in our game
const NEXT_TURN = {
  O: 'X',
  X: 'O',
}

const initialState = {
  grid: newTicTacToeGrid(),
  turn: 'X',
}

const reducer = (state, action) => {
  switch (action.type) {
    case 'CLICK': {
      const { x, y } = action.payload
      // Since we need immutable updates, I often find the simplest thing to do
      // is to clone the current state, and then use mutations on the clone to
      // make updates for the next state
      const nextState = clone(state)
      const { grid, turn } = nextState

      // If the cell already has a value, clicking on it should do nothing
      // Also, pay attention, because our rows are first, the `y` value is the
      // first index, the `x` value second. This takes some getting used to.
      if (grid[y][x]) {
        return state
      }

      // If we're here in our program, we can assign this cell to the current
      // `turn` value
      grid[y][x] = turn

      // Now that we've used this turn, we need to set the next turn. It might
      // be overkill, but I've used an object enum to do this.
      nextState.turn = NEXT_TURN[turn]

      // We'll add checks for winning or drawing soon

      return nextState
    }

    default:
      return state
  }
}

function Game() {
  const [state, dispatch] = React.useReducer(reducer, initialState)
  const { grid } = state

  const handleClick = (x, y) => {
    dispatch({ type: 'CLICK', payload: { x, y } })
  }

  return <Grid grid={grid} />
}

Let's break this code down. We start with clone which is a helper function I will use to deeply clone the state so I can make mutations on the clone and return that. Remember, we need to make immutable updates with a reducer, so that it renders the new state.

Next, I create a simple enum to be able to easily get the next turn. You could probably do this with a simple ternary, but there are other places this information will be useful in the future.

After that, we create our initialState and a reducer. Our initialState has our grid and the current turn, and our reducer handles only one action at the time being, CLICK.

On a CLICK action, we use the x and y values of the payload to determine the next state. If the grid cell at those coordinates has a value, we return the current state so we don't trigger an update. Otherwise, we can set the value of that cell to the current turn, update the nextState.turn property and return the nextState. Putting this all together, we should have a Game that works like this:

Interactive

Awesome. Our game is working... kind of. We can fill up the squares, but we don't have any means of resetting the game if we want to (or a draw occurs), and we don't have a way of determining if someone has won the game. Heck, we don't even know who's turn it is! Let's add these features next.

Let's start with the simplest of those tasks, whose turn it is:

function Game() {
  const [state, dispatch] = React.useReducer(reducer, initialState)
  const { grid, turn } = state
  const handleClick = (x, y) => {
    dispatch({ type: 'CLICK', payload: { x, y } })
  }

  return (
    <div style={{ textAlign: 'center' }}>
      <div>Next up: {turn}</div>      <Grid Cell={ButtonCell} grid={grid} handleClick={handleClick} />
    </div>
  )
}

And let's add a reset button, too:

// Changing this into a function ensures that we get a new state object
// and run any of the functions inside of it. This is useful in other games
// where the starting grid may have randomized bits of state
const getInitialState = () => ({  grid: newTicTacToeGrid(),  turn: 'X'})
const reducer (state, action) => {
  switch (action.type) {
    case 'RESET':      return getInitialState()
    //... remains the same
  }
}

function Game() {
  const [state, dispatch] = React.useReducer(reducer, getInitialState())
  const { grid, turn } = state

  const handleClick = (x, y) => {
    dispatch({ type: 'CLICK', payload: { x, y } })
  }

  const reset = () => {
    dispatch({ type: 'RESET' })
  }

  return (
    <div style={{ textAlign: 'center' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <div>Next up: {turn}</div>
        <button onClick={reset} type="button">Reset</button>      </div>
      <Grid Cell={ButtonCell} grid={grid} handleClick={handleClick} />
    </div>
  )
}

Now, our tic-tac-toe game looks like this:

Interactive
Next up: X

This is great. You, the reader and user can play, but it'd be really nice if the game told you if you won or not. Checking for win conditions is probably the most important part of creating a game like this (and is likely what the interviewer is looking for if you're asked to make a game like this).

We need to create an efficient algorithm that checks for the game at each step for a win. What are the win conditions of tic-tac-toe?

  • The game is won when 3 cells in a line contain the same value
  • A line is defined as a row, column, or diagonal

Let's break this down. At any given time, we need to check if three values are equal. There also happens to be only 8 lines in our game: 3 rows, 3 columns, and 2 diagonals. With these two observations, we can create a simple algorithm to determine if the game has been won at any point.

We'll start by making a helper function to evaluate our three values. We know if a sqaure is null, we can return false immediately. Then we just need to know if all three values are the same. Since we're using the strings X and O, this is very simple to do:

const checkThree = (a, b, c) => {
  // If any of the values are null, return false
  if (!a || !b || !c) return false
  return a === b && b === c
}

Now, we need to create our 8 lines, get the values at those indices, and run checkThree on each line. I'm going to add a helper function that will flatten our 2-dimensional array into a 1-dimensional array. This will make this our checks a little bit easier to do. Flattening an array like this might be too inefficient in certain situations, so be careful and pay attention to the performance of your program, but in general, this a good trick to know.

// Depending on your JavaScript environment, you can potentially
// use Array.prototype.flat to do this
const flatten = array => array.reduce((acc, cur) => [...acc, ...cur], [])

function checkForWin(flatGrid) {
  // Because our grid is flat, we can use array destructuring to
  // define variables for each square, I will use the points on a
  // compass as my variable names
  const [nw, n, ne, w, c, e, sw, s, se] = flatGrid

  // Then we simply run `checkThree` on each row, column and diagonal
  // If it's true for any of them, the game has been won!
  return (
    checkThree(nw, n, ne) ||
    checkThree(w, c, e) ||
    checkThree(sw, s, se) ||
    checkThree(nw, w, sw) ||
    checkThree(n, c, s) ||
    checkThree(ne, e, se) ||
    checkThree(nw, c, se) ||
    checkThree(ne, c, sw)
  )
}

We want to add our checkForWin function inside the CLICK case of our reducer. This will enable us to transition the game state into a winning state as soon as a win is detected. However, we have not setup any state that tracks whether the game has been won or not yet. We'll also want to add a status property to our state that we can update when a win is found.

const getInitialState = () => ({
  grid: newTicTacToeGrid(),
  status: 'inProgress',  turn: 'X',
})

const reducer (state, action) => {
  switch (action.type) {
    //... still the same

    case 'CLICK': {
      const { x, y } = action.payload
      const nextState = clone(state)
      const { grid, turn } = nextState

      if (grid[y][x]) {
        return state
      }

      grid[y][x] = turn

      const flatGrid = flatten(grid)
      if (checkForWin(flatGrid)) {        nextState.status = 'success'        return nextState      }
      nextState.turn = NEXT_TURN[turn]

      return nextState
    }

    //... still the same
  }
}

Now that we have a status, we can use it in our Game component to display when we have a winner. I prefer to use enums over booleans for tracking states, and generally this results in making enumerated objects to respond to those states. In order to render some text when the game is won, I'll create a GAME_STATUS_TEXT enum. This enum is a method for each game status we have. This method can can receive the current turn as an argument, and render whatever is necessary. Almost always, this will return null, but when someone wins, we can tell them.

const GAME_STATUS_TEXT = {  inProgress: () => null,  success: turn => `${turn} won!`,}
function Game() {
  const [state, dispatch] = React.useReducer(reducer, getInitialState())
  const { grid, status, turn } = state
  const handleClick = (x, y) => {
    dispatch({ type: 'CLICK', payload: { x, y } })
  }

  const reset = () => {
    dispatch({ type: 'RESET' })
  }

  return (
    <div style={{ textAlign: 'center' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <div>Next up: {turn}</div>
        <div>{GAME_STATUS_TEXT[status](turn)}</div>        <button onClick={reset} type="button">
          Reset
        </button>
      </div>
      <Grid Cell={ButtonCell} grid={grid} handleClick={handleClick} />
    </div>
  )
}

Before we check out this iteration of the game, I want to add something to our reducer to prevent a user from clicking anything but the reset button after a game has been won. By preventing this, I prevent some awkward state where after the first player has won the game, the second player can't "steal" by clicking a square that gives them three in a row. I also want to show this to demonstrate that a reducer does not have to be just a switch statement.

const reducer = (state, action) => {
  if (state.status === 'success' && action.type !== 'RESET') {
    return state
  }

  //... still the same
}

Now the only action a user can take when the game has been won is to reset the game. This seems fair to me. Check it out:

Interactive
Next up: X

There is just one more thing I want to add to this game. I would like the game to reset immediately when the board is filled with a draw. Doing this just seems like a nice touch to add.

We could do the work of trying to figure out when a game cannot be won and indicate that the game is drawn to the users, but I will leave that to you to try and figure out and implement. For now, let's add a checkForDraw function to our game to bring this to completion.

Adding checkForDraw is simpler than it might seem. First, we need to know that the game has not been won. The game can't be a draw if it's been won. We can reuse checkForWin for that.

Once we know that the game has not been won, we need to determine if the grid has been completely filled in. Given that our values are null, X, or O, we can determine this by filtering our flatGrid with the Boolean constructor. If the filtered grid has a length that is less than the length of the flatGrid, then we know we have null squares and the game is not drawn yet.

function checkForDraw(flatGrid) {
  return (
    !checkForWin(flatGrid) &&
    flatGrid.filter(Boolean).length === flatGrid.length
  )
}

We add this into our CLICK case in our reducer, like so:

const reducer (state, action) => {
  switch (action.type) {
    //... still the same

    case 'CLICK': {
      const { x, y } = action.payload
      const nextState = clone(state)
      const { grid, turn } = nextState

      if (grid[y][x]) {
        return state
      }

      grid[y][x] = turn

      const flatGrid = flatten(grid)

      if (checkForWin(flatGrid)) {
        nextState.status = 'success'
        return nextState
      }

      if (checkForDraw(flatGrid)) {        return getInitialState()      }
      nextState.turn = NEXT_TURN[turn]

      return nextState
    }

    //... still the same
  }
}
Interactive
Next up: X

Conclusion

There you have it! A working game of tic-tac-toe in React in about 130ish lines of code. I'm pretty generous with the whitespace usage.

Now, what was the point of all this? It certainly wasn't to try and make something you can do in a matter of seconds on a piece of paper with a pencil. It was to get our brains to think about solving problems, and more importantly, figuring out what those fundamental problems are. This is programming. Breaking requirements and ideas down until they can be turned into code.

Learning to code tic-tac-toe can be a gateway to all sorts of other learning. You can now practice building other games, or learn other algorithms. You can even take what you know here and apply it to other languages. Learning to code the same app in different ways is also good practice for your brain.

I encourage you to try and take this a step further some how. Make it better. Make it faster. I would love to see what you come up with.

Sharing this article on Twitter is a great way to help me out and I really appreciate the support.
+0
Liked the post? Click the beard a few times.
Tags
ReactJavaScript
Newer Post: Mental Model of Use Effect

Let's talk some more about JavaScript, React, and software engineering.

I write a newsletter to share my thoughts and the projects I'm working on. I would love for you to join the conversation. You can unsubscribe at any time.

Introduction to State Machines and XState Logo
Introduction to State Machines and XState

Check out my courses!

Liked the post? You might like my video courses, too. Click the button to view this course or go to Courses for more information.
Kyle Shevlin's face, which is mostly a beard with eyes
Kyle Shevlin is a front end web developer and software engineer who specializes in JavaScript and React.