Building a Whack a Mole Like Game with React

#Javascript #React

In this article we will use React to build a clone of the Whack a Mole arcade game. Not only will we implement the core game mechanics, but we will also enhance it with additional features. We'll explore how to add a dynamic countdown timer, and we'll even provide the option for players to personalize the game by using their own pictures as replacements for the mole images.

image for Building a Whack a Mole Like Game with React

Project Setup

In this article we will build a clone of the classic "Whack a Mole" arcade game with React.
The player will have to click on "moles" as they pop out of the ground in random places. We will try to enhance it a bit by adding a countdown and an option for players to personalize the game by using their own pictures as replacements for the mole images.
You can find the starter code in this Github repository.
We will begin on the "start" branch. You can simply install the project with:

npm install

Then run it with:

npm run dev

The App component provides a basic structure for our React application. The Header and Footer are already done and we will not be changing them in the next steps.
Most of our application logic will be put in the Game component and new components that will go inside it. We also provide some global CSS in the index.css file what we will not detail here.

src/App.jsx

import Header from './components/Header'
import Game from './components/Game'
import Footer from './components/Footer'

function App() {
  return (
    <>
      <Header />
      <main>
        <Game />
      </main>
      <Footer />
    </>
  )
}

export default App

Generating the Moles

In this step we will create an array of 9 "moles" and render the gameboard as a 3x3 grid of mole holes.
We will also create a button to start the game.

In the Game component, we create two states variables: gameOver and moles.
gameOver is initialized to true and moles is initialized to an empty array.

The generateMoles function is defined to generate an array of mole objects with id and active properties. It calls setMoles to update the moles state with the new array of mole objects.
The startGame function updates the gameOver state to false and call the generateMoles function to start the game.
This startGame function is called when the user clicks on the "Start" button.

The component either renders the "Start" button when gameOver is true or the game board when it is false. The game board is a div with class name "mole-container". It has an inline style that sets the grid layout using gridTemplateColumns and gridTemplateRows. We have inlined this style in order to make the number of columns and rows configurable in a next step. The moles state is mapped over using the map function, and for each mole object, a div element with a key of mole.id is rendered. The className of the div is conditionally set to "mole active" or just "mole" based on the mole.active property. Inside the div, an hamster emoji "🐹" is rendered (because there is no mole emoji...). In the CSS file, this div has a style of "transform: scale(0)" when it is not active and "transform: scale(1)" when its parent div has the "active" class. This will make the "mole" pop out of their hole.

src/components/Game.jsx

import { useState } from 'react'

const Game = () => {
  const [gameOver, setGameOver] = useState(true)
  const [moles, setMoles] = useState([])

  const generateMoles = () => {
    const newMoles = []
    for (let i = 0; i < 9; i++) {
      newMoles.push({ id: i, active: false })
    }
    setMoles(newMoles)
  }

  const startGame = () => {
    setGameOver(false)
    generateMoles()
  }

  if (gameOver) {
    return <button onClick={startGame}>Start</button>
  }

  return (
    <div
      className='mole-container'
      style={{
        gridTemplateColumns: `repeat(3, 1fr)`,
        gridTemplateRows: `repeat(3, 1fr)`,
      }}
    >
      {moles.map((mole, index) => (
        <div key={mole.id} className={`mole ${mole.active ? 'active' : ''}`}>
          <div>🐹</div>
        </div>
      ))}
    </div>
  )
}

export default Game

You can find the code for this step on the generating_moles branch.

At this point, when you click on the "Start" button, you should get something like this:

Screen shot of a whack a mole gameboard, with all moles hidden

Randomly Activating Moles

In this step, we want to randomly active a mole so that it pops out of its hole and then goes back in after some time, and so on.

In the Game component, we create two new functions: activateMole and deactivateMole.
The activateMole function calculates a random index between 0 and 8 and then set the active property to true for the mole at the corresponding index in the moles array. Then it sets a timeout for a random time between 400 and 1000 milliseconds before calling the deactivateMole function and passing it the previously activated mole index.
The deactivateMole function is setting back to false the active property according to the mole index it receives as argument. Then it sets a timeout for a random time between 400 and 1000 milliseconds before calling the activateMole function.

We do not need to make any change in the rendered elements for this step.

src/components/Game.jsx

import { useState } from 'react'

const Game = () => {
  const [gameOver, setGameOver] = useState(true)
  const [moles, setMoles] = useState([])

  const generateMoles = () => {
    const newMoles = []
    for (let i = 0; i < 9; i++) {
      newMoles.push({ id: i, active: false })
    }
    setMoles(newMoles)
  }

  const activateMole = () => {
    const moleIndex = Math.floor(Math.random() * 9)
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[moleIndex].active = true
      return updatedMoles
    })

    setTimeout(() => {
      deactivateMole(moleIndex)
    }, Math.floor(Math.random() * 600 + 400))
  }

  const deactivateMole = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].active = false
      return updatedMoles
    })

    setTimeout(() => {
      activateMole()
    }, Math.floor(Math.random() * 600 + 400))
  }

  const startGame = () => {
    setGameOver(false)
    generateMoles()
    setTimeout(() => {
      activateMole()
    }, Math.floor(Math.random() * 600 + 400))
  }

  if (gameOver) {
    return <button onClick={startGame}>Start</button>
  }

  return (
    <div
      className='mole-container'
      style={{
        gridTemplateColumns: `repeat(3, 1fr)`,
        gridTemplateRows: `repeat(3, 1fr)`,
      }}
    >
      {moles.map((mole, index) => (
        <div key={mole.id} className={`mole ${mole.active ? 'active' : ''}`}>
          <div>🐹</div>
        </div>
      ))}
    </div>
  )
}

export default Game

You can find the code for this step on the activating_moles branch.

At this point, you should get something like this:

Animated gif of a whack a mole gameboard with moles popping out randomly

Handling Player Clicks on Moles

In this step we want to be able to handle the clicks of the player on moles. When a player effectively clicks on a mole, it should increase his score by one and make the mole disappear. We will also replace the mole by a 💥 emoji when it is hit.

In the Game component, we create a new score state variable that will store the user's score.

We add an onClick listener on the div that contains the mole, and we create the handleMoleClick function that it will call.
According to the index of the mole that was clicked, we update a "hit" property to true on this mole, that will allow us to display the 💥 instead of the 🐹. We also update its "active" property to false, this will hide the mole.
In the activateMole function we make a small change to reset the "hit" property of the mole to false in order to display it with the 🐹 emoji even if it was hit before.

In the rendered element we add an h2 that displays the score.

src/components/Game.jsx

import { useState } from 'react'

const Game = () => {
  const [gameOver, setGameOver] = useState(true)
  const [moles, setMoles] = useState([])
  const [score, setScore] = useState(0)

  const generateMoles = () => {
    const newMoles = []
    for (let i = 0; i < 9; i++) {
      newMoles.push({ id: i, active: false })
    }
    setMoles(newMoles)
  }

  const activateMole = () => {
    const moleIndex = Math.floor(Math.random() * 9)
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[moleIndex].hit = false
      updatedMoles[moleIndex].active = true
      return updatedMoles
    })

    setTimeout(() => {
      deactivateMole(moleIndex)
    }, Math.floor(Math.random() * 600 + 400))
  }

  const deactivateMole = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].active = false
      return updatedMoles
    })

    setTimeout(() => {
      activateMole()
    }, Math.floor(Math.random() * 600 + 400))
  }

  const startGame = () => {
    setGameOver(false)
    generateMoles()
    setTimeout(() => {
      activateMole()
    }, Math.floor(Math.random() * 600 + 400))
  }

  const handleMoleClick = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].hit = true
      updatedMoles[index].active = false
      return updatedMoles
    })
    setScore(score + 1)
  }

  if (gameOver) {
    return (
      <>
        <h2>Score: {score}</h2>
        <button onClick={startGame}>Start</button>
      </>
    )
  }

  return (
    <>
      <h2>Score: {score}</h2>
      <div
        className='mole-container'
        style={{
          gridTemplateColumns: `repeat(3, 1fr)`,
          gridTemplateRows: `repeat(3, 1fr)`,
        }}
      >
        {moles.map((mole, index) => (
          <div key={mole.id} className={`mole ${mole.active ? 'active' : ''}`}>
            <div onClick={() => handleMoleClick(index)}>
              {mole.hit ? '💥' : '🐹'}
            </div>
          </div>
        ))}
      </div>
    </>
  )
}

export default Game

You can find the code for this step on the handling_user_click branch.

Adding a Countdown

For now, when we start a new game it never ends. In this step, we will add a countdown that will set the gameOver state to true after 30 seconds.

We begin by creating a new Countdown component.
It will receive two props from its parent Game component: endGame which is a function that gets called when the timer reaches 0, and gameOver.
We create a countdown state variable to store the current value of the timer, and two useEffect hooks.
The first one sets the initial value of countdown to 30 when the component mounts, and the second one updates the timer every second and calls the endGame() function when the timer reaches 0, it also cleans up the timeout using the return statement.

The component renders a countdown timer with a ⏳ emoji and the current value of countdown as text.

src/components/Countdown.jsx

import { useState, useEffect } from 'react'

const Countdown = ({ endGame, gameOver }) => {
  const [countdown, setCountdown] = useState(null)

  useEffect(() => {
    setCountdown(30)
  }, [])

  useEffect(() => {
    let timeout = null
    if (countdown && !gameOver) {
      timeout = setTimeout(() => {
        setCountdown(countdown - 1)
      }, 1000)
    }

    if (countdown === 0) {
      endGame()
    }

    return () => {
      if (timeout) {
        clearTimeout(timeout)
      }
    }
  }, [countdown])

  return (
    <div className='countdown'>
      <span>⏳</span>
      {countdown}
    </div>
  )
}

export default Countdown

Then we import our new Countdown component in the Game component.
Now that we can restart a new game, we need to make sure our timeouts are cleared. In order to do so, we create a new moleTimeout state variable. Each time we call a setTimeout, we will keep track of the timeout identifier in this state variable. We will use the useEffect hook cleanup function to clear it each time it changes.
We create the endGame function, which simply sets the gameOver state variable to true.
In the startGame function, we add two lines to make sure the score is initialized to 0 and the moles to an empty array when we restart a new game.

And we add our Countdown component below the score.

src/components/Game.jsx

import { useState, useEffect } from 'react'
import Countdown from './Countdown'

const Game = () => {
  const [gameOver, setGameOver] = useState(true)
  const [moles, setMoles] = useState([])
  const [score, setScore] = useState(0)
  const [moleTimeout, setMoleTimeout] = useState(null)

  const generateMoles = () => {
    const newMoles = []
    for (let i = 0; i < 9; i++) {
      newMoles.push({ id: i, active: false })
    }
    setMoles(newMoles)
  }

  const activateMole = () => {
    const moleIndex = Math.floor(Math.random() * 9)
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[moleIndex].hit = false
      updatedMoles[moleIndex].active = true
      return updatedMoles
    })

    setMoleTimeout(
      setTimeout(() => {
        deactivateMole(moleIndex)
      }, Math.floor(Math.random() * 600 + 400))
    )
  }

  const deactivateMole = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].active = false
      return updatedMoles
    })

    setMoleTimeout(
      setTimeout(() => {
        activateMole()
      }, Math.floor(Math.random() * 600 + 400))
    )
  }

  const startGame = () => {
    setScore(0)
    setMoles([])
    setGameOver(false)
    generateMoles()
    setMoleTimeout(
      setTimeout(() => {
        activateMole()
      }, Math.floor(Math.random() * 600 + 400))
    )
  }

  const handleMoleClick = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].hit = true
      updatedMoles[index].active = false
      return updatedMoles
    })
    setScore(score + 1)
  }

  const endGame = () => {
    setGameOver(true)
  }

  useEffect(() => {
    return () => {
      clearTimeout(moleTimeout)
    }
  }, [moleTimeout])

  if (gameOver) {
    return (
      <>
        <h2>Score: {score}</h2>
        <button onClick={startGame}>Start</button>
      </>
    )
  }

  return (
    <>
      <h2>Score: {score}</h2>
      <Countdown endGame={endGame} gameOver={gameOver} />
      <div
        className='mole-container'
        style={{
          gridTemplateColumns: `repeat(3, 1fr)`,
          gridTemplateRows: `repeat(3, 1fr)`,
        }}
      >
        {moles.map((mole, index) => (
          <div key={mole.id} className={`mole ${mole.active ? 'active' : ''}`}>
            <div onClick={() => handleMoleClick(index)}>
              {mole.hit ? '💥' : '🐹'}
            </div>
          </div>
        ))}
      </div>
    </>
  )
}

export default Game

You can find the code for this step on the adding_countdown branch.

Adding Configuration Options

Our game is now fully playable, but we want to add some configuration options to make it more enjoyable. First we want to be able to change the number of columns and rows of our game board. Then we also want to be able to use a custom picture instead of the 🐹 emoji, like for example a picture of our boss 😂.

We begin by creating a new Config component that will hold all the necessary input elements.
It receives several props including handleImageChange which is a function that gets called when the user selects a custom image, columns and rows which are numbers representing the current number of columns and rows for the game, and setColumns and setRows which are functions to update the columns and rows values respectively. We will pass these props from the parent Game component.

The component renders an input file to select the custom image and two inputs number, to set the number of columns and rows. We limit it between 3 and 6, otherwise the game board might get too small or too big.

src/components/Config.jsx

const Config = ({ handleImageChange, columns, setColumns, rows, setRows }) => {
  return (
    <div>
      <div>
        <label htmlFor='image'>
          Use your custom image{' '}
          <small>(works better with a square image)</small>:
        </label>
        <br />
        <input
          id='image'
          type='file'
          onChange={handleImageChange}
        />
      </div>
      <div>
        <label htmlFor='columns'>Number of columns:</label>
        <input
          id='columns'
          type='number'
          min={3}
          max={6}
          value={columns}
          onChange={(e) => setColumns(Math.min(6, Math.max(3, e.target.value)))}
        />
      </div>
      <div>
        <label htmlFor='rows'>Number of rows:</label>
        <input
          id='rows'
          type='number'
          min={3}
          max={6}
          value={rows}
          onChange={(e) => setRows(Math.min(6, Math.max(3, e.target.value)))}
        />
      </div>
    </div>
  )
}

export default Config

Then we import our new Config component in the Game component.
We create three new state variables: columns, rows and fileDataURL. The total number of moles was initally set to 9, we need to replace it with columns * rows.
We also need to use the new columns and rows variables in the inline style for the gridTemplateColumns and gridTemplateRows properties. The fileDataURL will contain the url of the custom image.
We create a handleImageChange function, that we pass to our Config component, it will be called when the user selects a custom image. It reads the file data URL and updates the state variable fileDataURL with the URL of the selected image.

We add the Config component to the elements that are rendered when the game is not started yet (gameOver is true), above the "Start" button.
In the div that contains the 🐹 emoji, we now check if a custom image was selected, i.e. fileDataURL is not null, in this case we render this image instead of the emoji.

src/components/Game.jsx

import { useState, useEffect } from 'react'
import Countdown from './Countdown'
import Config from './Config'

const Game = () => {
  const [gameOver, setGameOver] = useState(true)
  const [moles, setMoles] = useState([])
  const [score, setScore] = useState(0)
  const [moleTimeout, setMoleTimeout] = useState(null)
  const [columns, setColumns] = useState(4)
  const [rows, setRows] = useState(4)
  const [fileDataURL, setFileDataURL] = useState(null)

  const generateMoles = () => {
    const newMoles = []
    for (let i = 0; i < columns * rows; i++) {
      newMoles.push({ id: i, active: false })
    }
    setMoles(newMoles)
  }

  const activateMole = () => {
    const moleIndex = Math.floor(Math.random() * columns * rows)
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[moleIndex].hit = false
      updatedMoles[moleIndex].active = true
      return updatedMoles
    })

    setMoleTimeout(
      setTimeout(() => {
        deactivateMole(moleIndex)
      }, Math.floor(Math.random() * 600 + 400))
    )
  }

  const deactivateMole = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].active = false
      return updatedMoles
    })

    setMoleTimeout(
      setTimeout(() => {
        activateMole()
      }, Math.floor(Math.random() * 600 + 400))
    )
  }

  const startGame = () => {
    setScore(0)
    setMoles([])
    setGameOver(false)
    generateMoles()
    setMoleTimeout(
      setTimeout(() => {
        activateMole()
      }, Math.floor(Math.random() * 600 + 400))
    )
  }

  const handleMoleClick = (index) => {
    setMoles((prevMoles) => {
      const updatedMoles = [...prevMoles]
      updatedMoles[index].hit = true
      updatedMoles[index].active = false
      return updatedMoles
    })
    setScore(score + 1)
  }

  const endGame = () => {
    setGameOver(true)
  }

  useEffect(() => {
    return () => {
      clearTimeout(moleTimeout)
    }
  }, [moleTimeout])

  const handleImageChange = (e) => {
    const file = e.target.files[0]
    if (file) {
      const fileReader = new FileReader()
      fileReader.onload = (e) => {
        const { result } = e.target
        if (result) {
          setFileDataURL(result)
        }
      }
      fileReader.readAsDataURL(file)
    }
  }

  if (gameOver) {
    return (
      <>
        <h2>Score: {score}</h2>
        <Config
          handleImageChange={handleImageChange}
          columns={columns}
          setColumns={setColumns}
          rows={rows}
          setRows={setRows}
        />
        <button onClick={startGame}>Start</button>
      </>
    )
  }

  return (
    <>
      <h2>Score: {score}</h2>
      <Countdown endGame={endGame} gameOver={gameOver} />
      <div
        className='mole-container'
        style={{
          gridTemplateColumns: `repeat(${columns}, 1fr)`,
          gridTemplateRows: `repeat(${rows}, 1fr)`,
        }}
      >
        {moles.map((mole, index) => (
          <div key={mole.id} className={`mole ${mole.active ? 'active' : ''}`}>
            <div onClick={() => handleMoleClick(index)}>
              {mole.hit ? (
                '💥'
              ) : fileDataURL ? (
                <img src={fileDataURL} draggable='false' />
              ) : (
                '🐹'
              )}
            </div>
          </div>
        ))}
      </div>
    </>
  )
}

export default Game

And we are now done with our Whack a Mole Like game, of course we could still make a lot of improvements, like handling errors for the inputs, e.g. if the file is not an image.

You can find the final code on the adding_config branch.

You can try an online demo at https://whack-a-mole.jycurien.fr/.

Animated gif of a whack a mole game being played

Last updated 11 months ago