Building Your First Browser Game with Three.js and React: Part 4 - Adding Game Mechanics

Welcome to the final post of our series on creating a browser game with Three.js and React. In this piece, we'll introduce game mechanics to our mini-basketball game. We'll specifically establish a scoring system using Zustand for state management and configure colliders in our Table component to detect when the ball passes through the hoop.

Step 1: Setting Up Zustand

First, we're going to use Zustand to manage our game score. Zustand is a lightweight, fast, and scalable state management solution for React.

While there are many state management options for React, I favor Zustand for its simplicity and it’s created by Pmndrs, the creators of R3F!

We need state management to store the score of the current game. This allows us to trigger increments in our table component and pass these values to our Interface. State management simplifies the process of sharing values across all our components.

To start, we need to install it:

 npm install zustand

With Zustand installed, let's proceed to create the store. This is a file used for defining the values to be stored and shared across your components.

To do this, create a new file named src/stores/useGame.js and set up the store:

import { create } from 'zustand'

export default create((set) => {
  return {
    /**
     * Score
    */
    score: 0,
    increment: () => set((state) => ({ score: state.score + 1 })),
  }
})

Our store is straightforward, containing just one variable: score, which keeps track of the number of balls we score. It also has a simple function increment that increases this score.

Step 2: Displaying the Score

Next, we'll display the score on the game interface by creating a very simple HTML interface.

First, create a new file named Interface.jsx inside the src/ directory:

import useGame from "./stores/useGame"

const Interface = () => {
  const points = useGame((state) => state.score)

  return <div className="points">
    <h1>{points} points</h1>
  </div>
}

export default Interface

In this interface, we utilize our store to retrieve and display the actual game score within a div inside an h1 tag.

Next, we need to import it into our app. Since this component is not 3D-related, let's add it to our main.jsx file outside of our Canvas:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Canvas } from '@react-three/fiber'
import Experience from './Experience'
import './index.css'
import Interface from './Interface'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Interface />
    <Canvas>
      <Experience />
    </Canvas>
  </React.StrictMode>
)

You should see your interface in the top-left corner of the scene, as shown:

Screenshot of the ugly interface

While it isn't bad, it’s very ugly. Let's add some CSS to enhance its appearance slightly. Open the existing index.css file and append the following at the end:

.points {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    z-index: 999;
    display: flex;
    justify-content: center;
    align-items: center;
    color: #fff;
    font-size: 1.25rem;
}

Great! The text is now centered, enlarged, white, and a little less unattractive (you can still improve it yourself!):

Screenshot of the less ugly interface

Step 3: Implementing Colliders

Now, we need to detect when the ball passes through the hoop using colliders in the Table component.

Colliders not only add physics to our object but also define areas where objects are detected when passing through.

Let's add our first collider to the hoop. Locate the nodes.Ring.geometry within the Table component:

const Table = (props) => {
  return (
    <group {...props}>
      {/* ... */}
      <CuboidCollider
        args={[0.35, 0, 0.35]}
        position={[-1.686, 1.40, 0]}
        sensor
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Ring.geometry}
          material={materials.Red}
          position={[0, 0.06, 0]}
        />
      </CuboidCollider>
      {/* ... */}
    </group>
  );
};

We've added a custom CuboidCollider to our Ring with a unique property sensor. A sensor allows for specific functions like onIntersectionEnter or onIntersectionExit. These functions can detect when an object enters or exits this specific collider. If we define a collider with the sensor, this collider won't generate any contact points. Its only purpose is to detect other objects.

Remember to adjust the position of our Ring. This is because now it's the parent collider that defines the position of our mesh.

Now, if you look inside your ring, you should see the cuboid collider as illustrated below:

Screenshot of the collider of the ring

This component should detect when the ball enters it. To achieve this, we simply need to add a function at the beginning of our component and use it in our collider:

import useGame from '../stores/useGame';

const Table = (props) => {
	// ...
  const increaseScore = useGame((state) => state.increment)
  // ...

  return (
    <group {...props}>
      {/* ... */}
      <CuboidCollider
        args={[0.35, 0, 0.35]}
        position={[-1.686, 1.40, 0]}
        sensor
        onIntersectionExit={increaseScore}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Ring.geometry}
          material={materials.Red}
          position={[0, 0.06, 0]}
        />
      </CuboidCollider>
      {/* ... */}
    </group>
  );
};

We've created a function called increaseScore that utilizes the increment function from our store. This function is used within the onIntersectionExit property of the CuboidCollider.

You can now play your game! Let's score some balls.

However, there is a problem.

You may have noticed that sometimes the score increases by two, or if the ball bounces too much and enters the hoop a second time, the score increases multiple times.

We need to verify this. We need to determine if the ball is returning to our thruster.

To do this, we already have all the necessary information! We just need another collider! 🎉

Let's add this collider and implement some conditions for our goal:

import { useRef, useState } from 'react'
import useGame from '../stores/useGame';

const Table = (props) => {
  // ...
  const [isScored, setIsScored] = useState(false)
  const increaseScore = useGame((state) => state.increment)
  const goal = () => {
    if(!isScored) {
      setIsScored(true)
      increaseScore()
    }
  }
  // ...

  return (
    <group {...props}>
      <RigidBody type='fixed' colliders='trimesh' restitution={0.6} friction={0}>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Table.geometry}
          material={materials.Wood}
          position={[0, 0.068, 0]}
        />

	      {/* Add this collider inside the RigidBody of the Table mesh */}
        <CuboidCollider
          args={[0, 2, 1.5]}
          position={[1.5, 1.5, 0]}
          sensor
          onIntersectionExit={() => {
            setIsScored(false)
          }}
        />
      </RigidBody>
      {/* ... */}
        <CuboidCollider
          args={[0.35, 0, 0.35]}
          position={[-1.686, 1.40, 0]}
          sensor
          onIntersectionExit={goal}
        >
          <mesh
            castShadow
            receiveShadow
            geometry={nodes.Ring.geometry}
            material={materials.Red}
            position={[0, 0.06, 0]}
          />
        </CuboidCollider>
      {/* ... */}
    </group>
  );
};

Here's what we've done: we've added a state to our Table component using the useState hook. This state helps us determine whether we've already scored or not. We've also created a goal function to increment the score and set this state to true. This prevents indefinite score incrementing when the ball has already been scored but hasn't returned to the thruster yet.

To detect if the ball has returned to the thruster and is ready for another shot, we've placed a new CuboidCollider on our Table and reset the state to false.

You should now see the new collider positioned right in front of the thruster.

Screenshot of the collider of the table

Now, when we launch the ball and it passes through our hoop, the goal function checks if we've already scored. If we haven't, it increments our score and sets the state to true. When we pass through our new Collider in front of our thruster, this variable switches to false, and we're ready to score again.

This improvement prevents us from scoring multiple points with one shot like we could before.

Finally, we can go to our Experience.jsx file and delete the debug property from the Physics component.

Final screenshot of our game

Conclusion

Congratulations! You've successfully incorporated game mechanics into your browser game using Three.js and React. You now have a fully functioning mini-basketball game equipped with a scoring system. This marks the end of our series on building your first browser game. Continue to experiment and add new features to further enhance your game.

In the future, I'll include some bonus articles to further improve the game, such as adding confetti when scoring, and so on.

There's always room for improvement in this game! For instance, Aezakmi from the Three.js Journey Discord suggested adding some "randomness" to the force of the thruster. I've included his message below in case it sparks some ideas:

So basically you have "puncher" state between 0 and 1, where 0 is full down, and 1 is full up. To calculate the force you gotta do the (1.0 - puncherState)*forceValue . For example if the ball collide with puncher being halfway up, you gonna apply (1.0 - 0.5) * forceValue and if pucher is full up the force gonna be zero (1.0 - 1.0) * forceValue.

Now we need to calculate the direction for the force to apply. Thats gonna be even easier. Just do new Vector3().subVectors(ball.position, puncher.position).normalize().

This topic might be a bit advanced, but I believe everyone will be able to grasp it after this series!

Thanks for following me throughout this series of articles. Don't hesitate to share your feelings about it on Twitter.