Building Your First Browser Game with Three.js and React: Part 3 - Adding Interactivity and Physics

Introduction

Welcome back to our series on creating a 3D basketball game using Three.js and React in your browser! So far, we've set up our project and incorporated essential 3D models into our scene. Now, it's time to animate our game with interactivity and physics. This installment will focus on integrating physics to simulate realistic ball movements and interactions, making our game genuinely interactive and enjoyable.

Step 1: Setting Up Physics with @react-three/rapier

When it comes to integrating physics into React Three Fiber, several libraries are available. @react-three/rapier stands out as one of the most recent updated (created in 2019 and written in Rust), making it a great choice for adding physics in 3D web environments.

While Rapier is known for its ease of use and good performance, other libraries might also be excellent choices depending on specific use cases, like Cannon for example. (The fork by pmndrs, that is awesome too)

First, ensure that @react-three/rapier is installed in your project:

npm install @react-three/rapier

Initialize the physics engine in your Experience file where all our scene is set up.

To accomplish this, we add the Physics component, provided by rapier, around our Table and Ball. We don't need to include our lights, controls, or environment, as they don't require physics.

We are adding a debug parameter to the Physics component. This will allow us to view all the colliders of our object.

import React from 'react';
import { Box, Center, Environment, OrbitControls } from '@react-three/drei';
import Table from './Components/Table';
import Ball from './Components/Ball';
import { Physics } from '@react-three/rapier';

const Experience = () => {
  return (
    <>
      <color attach="background" args={["#ddc28d"]} />

      <ambientLight />
      <directionalLight position={[0, 1, 2]} intensity={1.5} />
      <Environment preset="city" />

      <OrbitControls makeDefault />

      <Physics debug>
        <Center>
          <Table position={[0, 0, 0]} />
          <Ball position={[0.25, 1.5, 0]} />
        </Center>
      </Physics>
    </>
  );
}

export default Experience;

This wrapper will manage all physics-related updates automatically.

Step 2: Adding Physics to the Ball

Let's add physics to our ball. We'll adjust the Ball component to include the RigidBody component from rapier.

import { RigidBody } from '@react-three/rapier';

const Ball = ({ position }) => {
  return (
    <group position={position} scale={0.7}>
      <RigidBody>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Sphere.geometry}
          material={materials["Material.001"]}
        />
      </RigidBody>
    </group>
	);
};

We also include a group in our mesh to set the position and scale for the ball, along with our rigid body. To slightly decrease the ball's size, we incorporate a scale parameter of 0.7, which we'll use later to fit the hoop ring.

This setup adds physics to our ball. You should see the ball falling into the depths of our scene. The ball falls because we have not integrated physics into our table yet.

Before the ball falls, you should see a square around it, as shown in the screenshot below. This square represents the object's colliders. By default, all colliders are square-shaped and adapt to the size of the object to which we add a RigidBody. However, we can adjust the shape using the colliders parameter of RigidBody.

Screenshot of the ball component with colliders see around

Let's try another collider, such as ball:

import { RigidBody } from '@react-three/rapier';

const Ball = ({ position }) => {
  const { nodes, materials } = useGLTF("/models/basketball.glb");

  return (
    <group position={position} scale={0.7}>
      <RigidBody colliders="ball">
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Sphere.geometry}
          material={materials["Material.001"]}
        />
      </RigidBody>
    </group>
  );
};

You should now see the collider in a "round shape" (like a ball). This is exactly what we want!

Screenshot of the ball component with ball collider see around

It exist some colliders provided by rapier:

  • cuboid , the default one, for square shape.
  • ball, the one we use, for round shape.
  • hull, a collider that fits a shape very closely but ignores holes within it. It's useful for complex objects without holes.
  • trimesh is similar to hull, but it doesn't ignore holes. While this collider is powerful, it can complicate collision detection and potentially introduce bugs. It's best to avoid it if possible, but for smaller projects with less collision, it’s acceptable. (Am I saying this because I use it in this project? Perhaps..)

Step 3: Adding physics to the Table

Now, let's modify the Table component to respond to the ball. This way, the ball will be "trapped" inside the table. First, let’s add RigidBody for the Table mesh:

import { RigidBody, useRigidBody } from '@react-three/rapier';

const Table = (props) => {
  return (
    <group {...props}>
      <RigidBody type='fixed'>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Table.geometry}
          material={materials.Wood}
          position={[0, 0.068, 0]}
        />
      </RigidBody>
	    {/* ... */}
    </group>
  );
};

Now, we should observe the ball falling and coming to rest on the table. We're adding a 'fixed' type to the rigid body because the table shouldn't move; it simply acts as a "floor" for our ball. Take note of the table's colliders as illustrated below:

Screenshot of our scene with colliders for ball and table

Our table has a shape more complex than a basic cuboid, so let's use trimesh instead (yes, I know, emergency collider only..).

import { RigidBody, useRigidBody } from '@react-three/rapier';

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

Now, you can observe that our collider perfectly matches the shape of our table.

Screenshot of our table with trimesh collider

Now that our base table is set up, we need to add physics to our glass as well. This should be the final mesh of the Table component (if you followed the code given in this serie):

import { RigidBody, useRigidBody } from '@react-three/rapier';

const Table = (props) => {
  return (
    <group {...props}>
      {/* ... */}
      <RigidBody type='fixed' colliders='trimesh'>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Glass.geometry}
          position={[0.497, 1.54, 0.005]}
        >
          <MeshTransmissionMaterial anisotropy={0.1} chromaticAberration={0.04} distortionScale={0} temporalDistortion={0} />
        </mesh>
      </RigidBody>
    </group>
  );
};

The last physics component to add is our basketball hoop. It comprises four meshes: Base, Cylinder, Panel, and Ring. Like the others, enclose these four meshes in a RigidBody with a fixed type and trimesh colliders.

import { RigidBody, useRigidBody } from '@react-three/rapier';

const Table = (props) => {
  return (
    <group {...props}>
      {/* ... */}
      <RigidBody type='fixed' colliders='trimesh'>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Base.geometry}
          material={materials.Wood}
          position={[-2.235, 0.565, 0]}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Cylinder.geometry}
          material={materials.Red}
          position={[-2.235, 1.177, 0]}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Panel.geometry}
          material={materials.Wood}
          position={[-2.234, 1.814, 0]}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Ring.geometry}
          material={materials.Red}
          position={[-1.686, 1.46, 0]}
        />
      </RigidBody>
      {/* ... */}
    </group>
  );
};

We have now integrated physics into all of our models (except for a few used for controls)! You should be able to see all the colliders in your scene like this:

Screenshot of our scene with all colliders activated

To observe our physics in action, let's adjust the ball's position slightly to the left and watch it roll across the table:

const Experience = () => {
  return (
    <>
      {/* ... */}
      <Physics debug>
        <Center>
          <Table position={[0, 0, 0]} />
          {/* Let's change the third parameter of position to 0.2 */}
          <Ball position={[0.25, 1.5, 0.2]} />
        </Center>
      </Physics>
    </>
  );
}

export default Experience;

Step 4: Tweak the Restitution and the Friction

Let's assign some default values to our existing physics to improve the ball's reaction to the floor, among other things.

Return to our Ball component and add restitution and friction to the RigidBody:

import { RigidBody } from '@react-three/rapier';

const Ball = ({ position }) => {
  const { nodes, materials } = useGLTF("/models/basketball.glb");

  return (
    <group position={position} scale={0.7}>
      <RigidBody colliders="ball" restitution={1} friction={0.2}>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Sphere.geometry}
          material={materials["Material.001"]}
        />
      </RigidBody>
    </group>
  );
};

Restitution refers to the "bounciness" of an object. The default value is 0, meaning the object does not bounce. We set it to 1 to give our basketball a lot of bounce!

Friction refers to the degree to which surfaces rub against each other. The default value is 0.7. The higher the friction, the quicker the object will stop. To let our ball slide more on the table, we’ll reduce the friction to 0.2.

You should see the ball jumping more. Feel free to tweak these parameters to observe the changes!

Now, let's return to our table. We will add both restitution and friction to the table and the glass.

import { RigidBody, useRigidBody } from '@react-three/rapier';

const Table = (props) => {
  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]}
        />
      </RigidBody>
      {/* ... */}
      <RigidBody type='fixed' colliders='trimesh' restitution={0.2} friction={0}>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Glass.geometry}
          position={[0.497, 1.54, 0.005]}
        >
          <MeshTransmissionMaterial anisotropy={0.1} chromaticAberration={0.04} distortionScale={0} temporalDistortion={0} />
        </mesh>
      </RigidBody>
    </group>
  );
};

You should see the ball jumping even more! We have now the perfect setup for our game. We can now add the controls.

Step 5: Integrating Controls

To make our game interactive, we need to give players control over the ball. We'll implement click controllers to trigger thrusters that propel the ball towards the basket ring.

First, we are going to add the “onclick” control on our Control_A and Control_B mesh.

Adding Control on Mesh

To identify our controls, we need to use useRef(), a hook from React that allows us to reference a value. This value can be anything we want, such as a boolean, a number, a string, or an object. After assigning this reference to our mesh, we can retrieve it later.

Let's incorporate it into our Table component:

import { useRef } from 'react'

const Table = (props) => {

  const controlA = useRef(null);
  const controlB = useRef(null);

  return (
    <group {...props}>
      {/* ... */}
      <mesh
        ref={controlA}
        castShadow
        receiveShadow
        geometry={nodes.Control_A.geometry}
        material={materials.Red}
        position={[4.184, 0.129, 0.744]}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Control_A_Text.geometry}
          material={materials.White}
          position={[0.237, 0.046, 0.21]}
          rotation={[Math.PI / 2, 1.179, -Math.PI / 2]}
        />
      </mesh>
      <mesh
        ref={controlB}
        castShadow
        receiveShadow
        geometry={nodes.Control_B.geometry}
        material={materials.Green}
        position={[4.183, 0.128, -0.754]}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Control_B_Text.geometry}
          material={materials.White}
          position={[0.25, 0.043, 0.207]}
          rotation={[Math.PI / 2, 1.184, -Math.PI / 2]}
        />
      </mesh>
      {/* ... */}
    </group>
  );
};

Now that we have our references to Control A and Control B, we need to activate the "click" control. For this, the <mesh> component from Three.js offers two functions that we can use: onPointerUp and onPointerDown.

We can use these functions to trigger a function we've created when a player clicks on ControlA or ControlB. Let's create these two functions and assign them to the controls:

import { useRef } from 'react'

const Table = (props) => {

  const controlA = useRef(null);
  const controlB = useRef(null);

  const clickUp = (controlRef) => {
    if (controlRef.current) {
      controlRef.current.position.y = 0.128
    }
  }

  const clickDown = (controlRef) => {
    if (controlRef.current) {
      controlRef.current.position.y = 0.128 - 0.1
    }
  }

  return (
    <group {...props}>
      {/* ... */}
      <mesh
        ref={controlA}
        castShadow
        receiveShadow
        geometry={nodes.Control_A.geometry}
        material={materials.Red}
        position={[4.184, 0.129, 0.744]}
        onPointerUp={() => clickUp(controlA)}
        onPointerDown={() => clickDown(controlA)}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Control_A_Text.geometry}
          material={materials.White}
          position={[0.237, 0.046, 0.21]}
          rotation={[Math.PI / 2, 1.179, -Math.PI / 2]}
        />
      </mesh>
      <mesh
        ref={controlB}
        castShadow
        receiveShadow
        geometry={nodes.Control_B.geometry}
        material={materials.Green}
        position={[4.183, 0.128, -0.754]}
        onPointerUp={() => clickUp(controlB)}
        onPointerDown={() => clickDown(controlB)}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Control_B_Text.geometry}
          material={materials.White}
          position={[0.25, 0.043, 0.207]}
          rotation={[Math.PI / 2, 1.184, -Math.PI / 2]}
        />
      </mesh>
      {/* ... */}
    </group>
  );
};

In this function, we "simulate" a click by slightly lowering the position of our control, similar to pressing a button. We trigger the clickDown action when the user clicks the button, and clickUp action when they release the click.

You can test it on your scene, you should see the button going down when clicking on it.

We can now proceed to the next part: adding physics to the thruster.

Adding Physics on Thrusters

In this section, we need to identify our thrusters using useRef, add a special rigid body to them, and activate them when we use the controls. Let's get started:

const Table = (props) => {

  // Add useRef on thruster to identify them
  const thrusterA = useRef(null);
  const thrusterB = useRef(null);


  const clickUp = (controlRef) => {
    if (controlRef.current) {
      controlRef.current.position.y = 0.128
    }
  }

  const clickDown = (controlRef) => {
    if (controlRef.current) {
      controlRef.current.position.y = 0.128 - 0.1
    }
  }

  return (
    <group {...props}>
      {/* ... */}
      <RigidBody
        ref={thrusterA}
        type="kinematicPosition"
        colliders="hull"
        lockRotations={true}
        enabledTranslations={[false, true, false]}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Thruster_A.geometry}
          material={materials.Black}
          position={[2.259, -0.189, 0.765]}
        />
      </RigidBody>
      <RigidBody
        ref={thrusterB}
        type="kinematicPosition"
        colliders="hull"
        lockRotations={true}
        enabledTranslations={[false, true, false]}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Thruster_B.geometry}
          material={materials.Black}
          position={[2.259, -0.189, -0.764]}
        />
      </RigidBody>
      {/* ... */}
    </group>
  );
};

We assign a specific type of RigidBody, kinematicPosition, to the thrusters. This type allows our object to be moved using special functions, such as translate/rotate, through code. The rigid body doesn't move until we instruct it to, and can't be moved by other rigid bodies, for example.

We also restrict rotations on this mesh, as we only want it to move up and down.

Now, we simply need to move it up or down when we press or release our controls. Let's do it:

const Table = (props) => {

  const thrusterA = useRef(null);
  const thrusterB = useRef(null);

  const clickUp = (controlRef) => {
    if (controlRef.current) {
      controlRef.current.position.y = 0.128

      if(controlRef === controlA) {
        const position = vec3(thrusterA.current.translation())
        thrusterA.current.setNextKinematicTranslation({
          x: position.x,
          y: position.y - 0.5,
          z: position.z
        })
      } else {
        const position = vec3(thrusterB.current.translation())
        thrusterB.current.setNextKinematicTranslation({
          x: position.x,
          y: position.y - 0.5,
          z: position.z
        })
      }
    }
  }

  const clickDown = (controlRef) => {
    if (controlRef.current) {
      controlRef.current.position.y = 0.128 - 0.1

      if(controlRef === controlA) {
        const position = vec3(thrusterA.current.translation())
        thrusterA.current.setNextKinematicTranslation({
	  x: position.x,
	  y: position.y + 0.5,
	  z: position.z
        })
      } else {
        const position = vec3(thrusterB.current.translation())
        thrusterB.current.setNextKinematicTranslation({
	  x: position.x,
	  y: position.y + 0.5,
	  z: position.z
        })
      }
    }
  }

  return (
    {/* ... */}
  );
};

Now, when we activate the clickUp and clickDown functions by pressing our controls, thruster A for control A and thruster B for control B will propel the ball across our table.

To accomplish this, we simply use the setNextKinematicTranslation function to apply a translation to our object. We just need to add a number, such as 0.5, to the y-axis to move it upwards.

When the user clicks, the thruster elevates by 0.5. When the user releases the click, the thruster reverts back to -0.5.

Small Tweak to Improve Game

To enhance our game, I propose adding a parameter to the rigid body of our Ball component: gravityScale. This parameter enables us to influence how gravity impacts our object. By default, this value simulates Earth's gravity (-9.81 approximately). However, I want the ball to be more energetic and faster, so let's introduce a new value:

const Ball = ({ position }) => {
  return (
    <group position={position} scale={0.7}>
      <RigidBody colliders="ball" restitution={1} friction={0.2} gravityScale={3.5}>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Sphere.geometry}
          material={materials["Material.001"]}
          />
      </RigidBody>
    </group>
  );
};

Step 6: Testing and Debugging

With physics and controls established, thoroughly testing the game is crucial to observe their interactions. Based on this testing, adjust the physics parameters and controls as needed. Observe how different settings alter the gameplay.

If you encounter any issues, please don't hesitate to contact me!

Conclusion

Congratulations! You have successfully added interactivity and physics to your browser-based basketball game. The ball now moves realistically, and players can control actions within the game. It's time to start scoring some baskets!

In the next installment, we will introduce game mechanics to make the game fully playable. The last step until our final render!

Stay tuned and happy coding! 🏀