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:
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!):
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:
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.
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.
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.