Build a Game with React Three Fiber and Recoil

The author
Stephen Castle3 years ago

Warning: Some Experience Required

This tutorial is intended for an audience that has some familiarity with React Three Fiber, and it might breeze past some concepts that could be confusing if you have never used it. If you would like a quick intro tutorial before getting started, try the Learn the Basics of React Three Fiber by Building a Three-Point Lighting Setup tutorial which covers the basics of installing and using React Three Fiber.

At the same time it is designed to be as simple and no nonsense as possible to illustrate the basics of connecting state and user input to a React Three Fiber scene so if you are willing to dig in to the code a little bit you could probably figure everything out even if you have never used React Three Fiber, just be aware of that heading in.

Also note that all the code in this tutorial will go in the same file, except for one section, which will specifically mention the file you should use. Refer to the finished Code Sandbox if you ever get lost with where a particular piece of code belongs.

Getting Started

Let's build a game in React using React Three Fiber and the Recoil state management library. If you've been following along with the previous posts in this series, you'll have some idea of what we are building, but since this is the first article where we really start to put together the pieces into something that resembles a game let's recap and talk about the features of React Fox.

We want to create a player-controlled ship that can be moved both up and down and left and right with the mouse. Moving the ship should also move a targeting HUD that indicates the direction the player can fire the ship's lasers. Clicking the left mouse button should fire the lasers in the direction of the targeting HUD, and if the laser hits an enemy, the enemy should be destroyed, and the score should be increased. There are many more things to do and details to worry about later, but let's just worry about big picture stuff for now.

Check out the Code Sandbox of the finished project to get an idea of how everything should work when we are done. The code is heavily commented if you feel ready to read through it and get a head start on the project. Otherwise, we are going to step through each major piece one at a time.

Starting Point

In the previous chapters, we already covered importing a ship model. So let's start from there. Here is a link to a Code Sandbox you can fork that includes the imported ship and lights. If you want to learn about how the ship geometry was loaded, you can read the first tutorial in this series here.

You Can Start This Tutorial from this Starting Point

Add a Moving Ground Plane

Add a ground plane below the ship with animation moving the ground plane towards the camera at a steady pace. This is how the game will simulate movement through space. We will keep our player's ship always at the center of the scene and move all the other objects around it. This is a helpful trick to simplify the game logic later on since it lets us use the players location as a point of reference for all the other actions.

Remember that to animate an object in React Three Fiber create a ref attached to the mesh, then add a useFrame hook, inside of which you can access the terrain ref, and set the position z coordinate to be increased on each frame. This will move the terrain .4 units closer to the camera on each frame.

const GROUND_HEIGHT = -50; // A Constant to store the ground height of the game.

// A Ground plane that moves relative to the player. The player stays at 0,0
function Terrain() {
  const terrain = useRef();

  useFrame(() => {
    terrain.current.position.z += 0.4;
  });
  return (
    <mesh
      visible
      position={[0, GROUND_HEIGHT, 0]}
      rotation={[-Math.PI / 2, 0, 0]}
      ref={terrain}
    >
      <planeBufferGeometry attach="geometry" args={[5000, 5000, 128, 128]} />
      <meshStandardMaterial
        attach="material"
        color="white"
        roughness={1}
        metalness={0}
        wireframe
      />
    </mesh>
  );
}
<Canvas style={{ background: "black" }}>
  <directionalLight intensity={1} />
  <ambientLight intensity={0.1} />
  <Suspense fallback={<Loading />}>
    <ArWing />
  </Suspense>
  <Terrain />
</Canvas>

Add Controls to Move the Ship

Now that we have a ship and some ground to fly over, add the player controls, allowing for movement of the ship on the X and Y-axis. Do this by monitoring the mouse position in a useFrame hook, and updating the ship's position and rotation values based on the mouse position.

Also update the ship rotation to keep it pointing towards the center of the screen and create a cool rolling effect as the ship moves from side to side. The multiples applied to the raw mouse position data before setting the ship position were chosen to get a nice semi-realistic movement that felt natural to play. Try changing them yourself to see the different effects you can get.

// The players ship model. On each frame, check the cursor position and move the ship to point in the
// correct direction.

function ArWing() {
  const [shipPosition, setShipPosition] = useState();

  const ship = useRef();
  useFrame(({ mouse }) => {
    setShipPosition({
      position: { x: mouse.x * 6, y: mouse.y * 2 },
      rotation: { z: -mouse.x * 0.5, x: -mouse.x * 0.5, y: -mouse.y * 0.2 },
    });
  });
  // Update the ships position from the updated state.
  useFrame(() => {
    ship.current.rotation.z = shipPosition.rotation.z;
    ship.current.rotation.y = shipPosition.rotation.x;
    ship.current.rotation.x = shipPosition.rotation.y;
    ship.current.position.y = shipPosition.position.y;
    ship.current.position.x = shipPosition.position.x;
  });

  const { nodes } = useLoader(GLTFLoader, "models/arwing.glb");

  return (
    <group ref={ship}>
      <mesh visible geometry={nodes.Default.geometry}>
        <meshStandardMaterial
          attach="material"
          color="white"
          roughness={1}
          metalness={0}
        />
      </mesh>
    </group>
  );
}

Add a Targetting HUD to Indicate Direction of Laser Fire

The ship is moving around now, but it would be hard for the player to know what direction they are aiming unless we add a targeting HUD. We can do this using a particular type of Three.js geometry called a sprite. A sprite is just a special case of plane geometry that always faces directly towards the camera, and uses a transparent SpriteTexture. It is used to place 2d art assets into a scene which is perfect for a HUD. Three.js Sprite Add two sprites that sit in front of the ship, pointing in the same direction as its current orientation. Just like the ship its self, they will also need to be updated whenever the mouse moves.

Below is all the code you need to add to create the target HUD. You can use the same target.png file from the Code Sandbox, or try using your own image. Refer to the code comments to understand what each part of the component is doing.

// Draws two sprites in front of the ship, indicating the direction of fire.
// Uses a TextureLoader to load transparent PNG, and sprite to render on a 2d plane facing the camera.
function Target() {
  // Create refs for the two sprites we will create.
  const rearTarget = useRef();
  const frontTarget = useRef();

  const loader = new TextureLoader();
  // A png with transparency to use as the target sprite.
  const texture = loader.load("target.png");

  // Update the position of both sprites based on the mouse x and y position. The front target has a larger scalar.
  // Its movement in both axis is exagerated since its farther in front. The end result should be the appearance that the
  // two targets are aligned with the ship in the direction of laser fire.
  useFrame(({ mouse }) => {
    rearTarget.current.position.y = -mouse.y * 10;
    rearTarget.current.position.x = -mouse.x * 30;

    frontTarget.current.position.y = -mouse.y * 20;
    frontTarget.current.position.x = -mouse.x * 60;
  });
  // Return a group containing two sprites. One positioned eight units in front of the ship, and the other 16 in front.
  // We give the spriteMaterial a map prop with the loaded sprite texture as a value/
  return (
    <group>
      <sprite position={[0, 0, -8]} ref={rearTarget}>
        <spriteMaterial attach="material" map={texture} />
      </sprite>
      <sprite position={[0, 0, -16]} ref={frontTarget}>
        <spriteMaterial attach="material" map={texture} />
      </sprite>
    </group>
  );
}

Don't forget to add the new component to the canvas. It doesn't matter what order.

<Canvas style={{ background: "black" }}>
  <RecoilRoot>
    <directionalLight intensity={1} />
    <ambientLight intensity={0.1} />
    <Suspense fallback={<Loading />}>
      <ArWing />
    </Suspense>
    <Target />
    <Terrain />
  </RecoilRoot>
</Canvas>

Use Recoil to Keep Track of Shared Game State

Now that we have our player ship and UI all setup, it's time to start adding other objects like lasers and enemies, but this introduces a new problem. How will we keep track of everything and share that information between components? For example, we need to know where each laser shot currently is, and what direction it was fired in. We also need to detect when a laser hits an enemy or the ground and remove it from the scene. All of this game state needs to be tracked somewhere and made available to our components so we can display the correct thing on the screen.

We can use the Recoil state library to maintain the state. Recoil is a good choice for this because it does not require much boilerplate and has a simple to use API with good performance characteristics.

In Recoil, you represent each piece of state as something called an atom. For more information on this, check out the Recoil documentation here.

Create a new file called gameState.js to hold the atoms. Then at the top of the new file import atom from Recoil as seen below. We will add exports for each atom we define to this file so we can use them in our main App.js file.

import { atom } from "recoil";

With the new file in place, start to think about each piece of state, we are going to need to implement our game and begin declaring atoms to represent it. You declare each atom for your app using a function called atom exported by the Recoil package, passing in an object containing a unique string to use as a key and an initial value.

Create an Atom to store the position of the player's ship

We need to know the position of the player ship for a few reasons. One is so that when the lasers are fired, we can initialize their position to the right place just in front of the ship, and set their velocity to the correct direction of fire. We will also need the player ship position to detect when enemy fire has hit the ship or if we have crashed.

export const shipPositionState = atom({
  key: "shipPosition", // unique ID (with respect to other atoms/selectors)
  default: { position: {}, rotation: {} }, // default value (aka initial value)
});

Create an Atom to store the Positions of Enemies

We also want to keep track of all the enemy positions. We will use this to detect when an enemy ship is destroyed and to move the enemies around. Let's store the enemies as an array of objects containing the x, y, and z coordinates.

export const enemyPositionState = atom({
  key: "enemyPosition", // unique ID (with respect to other atoms/selectors)
  default: [
    { x: -10, y: 10, z: -80 },
    { x: 20, y: 0, z: -100 },
  ], // default value (aka initial value)
});

Positions of our Lasers

Let's also store the current position of any active lasers. We will use this to detect if they intersect with an enemy so we can record a hit. Since the game starts without any lasers being fired, we can leave the default as an empty array.

export const laserPositionState = atom({
  key: "laserPositions", // unique ID (with respect to other atoms/selectors)
  default: [], // default value (aka initial value)
});

The Current Score

Finally, we should keep track of the score. We'll need to increase this when we destroy an enemy and display it to the user in a component. The score will start at 0 so set the default value to that.

export const scoreState = atom({
  key: "score", // unique ID (with respect to other atoms/selectors)
  default: 0, // default value (aka initial value)
});

Final State Definition

The final gameState.js file should look like this. You will be importing this back into app.js to connect our state to various components.

import { atom } from "recoil";

export const shipPositionState = atom({
  key: "shipPosition", // unique ID (with respect to other atoms/selectors)
  default: { position: {}, rotation: {} }, // default value (aka initial value)
});

export const enemyPositionState = atom({
  key: "enemyPosition", // unique ID (with respect to other atoms/selectors)
  default: [
    { x: -10, y: 10, z: -80 },
    { x: 20, y: 0, z: -100 },
  ], // default value (aka initial value)
});

export const laserPositionState = atom({
  key: "laserPositions", // unique ID (with respect to other atoms/selectors)
  default: [], // default value (aka initial value)
});

export const scoreState = atom({
  key: "score", // unique ID (with respect to other atoms/selectors)
  default: 0, // default value (aka initial value)
});

Use the Recoil State in your Components

To access Recoil values, you need to wrap your components in the RecoilRoot component. Only components under this provider will have access to the Recoil state. Usually you would put this at the very top of your component tree, but with React Three Fiber there is an issue putting it above the Canvas component. So we can put it just inside of our canvas, as seen below.

import { RecoilRoot } from "recoil";
<Canvas style={{ background: "black" }}>
  <RecoilRoot>
    <directionalLight intensity={1} />
    <ambientLight intensity={0.1} />
    <Suspense fallback={<Loading />}>
      <ArWing />
    </Suspense>
    <Target />
    <Terrain />
  </RecoilRoot>
</Canvas>

Refactor the Ship Component to use and Update Ship Position in Recoil State

The first piece of state we should connect is our Ship's position. Previously we were storing this inside a useState hook, which means it was only available internally to the component. We need to access the state position in other places, so let's refactor it to use Recoil state.

To use Recoil state in a React component is very similar to using the useState hook.

First import useRecoilState from Recoil and the shipPositionState atom from the file we created in the previous step. When using Recoil state, you use a combination of hooks, and references to your atoms to read and write to the state.

import { RecoilRoot, useRecoilState } from "recoil";
import { TextureLoader } from "three";
import { shipPositionState } from "./gameState"; //highlight-line

Then replace the useState hook from earlier with useRecoilState, and pass in the shipPositionState atom. If everything worked correctly, nothing should have changed, but now our ship position is being stored in Recoil, and we will be able to access it from other components. The ArWing component should now look like below.

function ArWing() {
  const [shipPosition, setShipPosition] = useRecoilState(shipPositionState); //highlight-line

  const ship = useRef();
  useFrame(({ mouse }) => {
    setShipPosition({
      position: { x: mouse.x * 6, y: mouse.y * 2 },
      rotation: { z: -mouse.x * 0.5, x: -mouse.x * 0.5, y: -mouse.y * 0.2 },
    });
  });
  // Update the ships position from the updated state.
  useFrame(() => {
    ship.current.rotation.z = shipPosition.rotation.z;
    ship.current.rotation.y = shipPosition.rotation.x;
    ship.current.rotation.x = shipPosition.rotation.y;
    ship.current.position.y = shipPosition.position.y;
    ship.current.position.x = shipPosition.position.x;
  });

  const { nodes } = useLoader(GLTFLoader, "models/arwing.glb");

  return (
    <group ref={ship}>
      <mesh visible geometry={nodes.Default.geometry}>
        <meshStandardMaterial
          attach="material"
          color="white"
          roughness={1}
          metalness={0}
        />
      </mesh>
    </group>
  );
}

Add a component to render the lasers

Now let's think about how to render our lasers. Remember, we created an atom to represent the lasers and made the default value an empty array. First, import a new hook from Recoil called useRecoilValue, and also import the laserPositionState from the gameState file.

import { RecoilRoot, useRecoilState, useRecoilValue } from "recoil"; //highlight-line
import { TextureLoader } from "three";
import { shipPositionState, laserPositionState } from "./gameState"; //highlight-line

Add a component called Lasers. Inside this component, use the useRecoilValue hook along with the laserPositionState atom to get the array of lasers from the state. Then in the return value, map over the array of lasers returning a cube mesh for each one. Notice that we are setting the position of each mesh to an x,y,z coordinate stored in the laser value. When we create the lasers, we will need to remember this so we add the correct information to the state.

// Draws all of the lasers existing in state.
function Lasers() {
  const lasers = useRecoilValue(laserPositionState);
  return (
    <group>
      {lasers.map((laser) => (
        <mesh position={[laser.x, laser.y, laser.z]} key={`${laser.id}`}>
          <boxBufferGeometry attach="geometry" args={[1, 1, 1]} />
          <meshStandardMaterial attach="material" emissive="white" wireframe />
        </mesh>
      ))}
    </group>
  );
}

This component will not need to update the position of the lasers, it just renders them at whatever position is stored in state. And of course, don't forget to add the new component to the Canvas.

<Canvas style={{ background: "black" }}>
  <RecoilRoot>
    <directionalLight intensity={1} />
    <ambientLight intensity={0.1} />
    <Suspense fallback={<Loading />}>
      <ArWing />
    </Suspense>
    <Target />
    <Lasers />
    <Terrain />
  </RecoilRoot>
</Canvas>

Add an invisible plane to capture clicks and create lasers

We need some way to capture the clicks from the mouse and then create a new laser. First, get the shipPosition with Recoil by using useRecoilValue. Then get the value and setter function for the laserPositionState array with useRecoilState. With those two pieces of state, we can create a clickable element to create lasers. Return a mesh with planeBufferGeometry and a meshStandardMaterial. On the meshStandardMaterial set visible={false} as a prop. This creates a transparent plane in front of the camera that can capture clicks. With this in place, you can add an onClick handler with an arrow function. Inside the arrow function, use the setLasers method from Recoil to set the atom value to a new array containing all of the previous items, plus a new one. The new item should be placed initially at x:0 y:0 z:0, and it should have a randomly assigned new ID to be used by React as the unique key. Each laser also needs a velocity vector containing an x velocity and a y velocity. This is driven by the current direction the ship is pointing when the laser is fired. We can get that value from the shipPosition x and y rotation. The scalars are required to make the laser move at the right speed on each axis.

// An invisible clickable element in the front of the scene.
// Manages creating lasers with the correct initial velocity on click.
function LaserController() {
  const shipPosition = useRecoilValue(shipPositionState);
  const [lasers, setLasers] = useRecoilState(laserPositionState);
  return (
    <mesh
      position={[0, 0, -8]}
      onClick={() =>
        setLasers([
          ...lasers,
          {
            id: Math.random(),
            x: 0,
            y: 0,
            z: 0,
            velocity: [
              shipPosition.rotation.x * 6,
              shipPosition.rotation.y * 5,
            ],
          },
        ])
      }
    >
      <planeBufferGeometry attach="geometry" args={[100, 100]} />
      <meshStandardMaterial
        attach="material"
        color="orange"
        emissive="#ff0860"
        visible={false}
      />
    </mesh>
  );
}

Finally, add it to the Canvas.

<Canvas style={{ background: "black" }}>
  <RecoilRoot>
    <directionalLight intensity={1} />
    <ambientLight intensity={0.1} />
    <Suspense fallback={<Loading />}>
      <ArWing />
    </Suspense>
    <Target />
    <Lasers />
    <Terrain />
    <LaserController />
  </RecoilRoot>
</Canvas>

Adding enemies to the Three.js scene

Adding enemies is very similar to adding the lasers. Get the array of enemy coordinates from Recoil with useRecoilValue. Then map over them and render a sphere for now at the location of each enemy. Because we had an initial value in the enemyPositionState atom. You should see two enemies rendered right away after adding this component.

// Manages Drawing enemies that currently exist in state
function Enemies() {
  const enemies = useRecoilValue(enemyPositionState);
  return (
    <group>
      {enemies.map((enemy) => (
        <mesh position={[enemy.x, enemy.y, enemy.z]} key={`${enemy.x}`}>
          <sphereBufferGeometry attach="geometry" args={[2, 8, 8]} />
          <meshStandardMaterial attach="material" color="white" wireframe />
        </mesh>
      ))}
    </group>
  );
}

Finally, add it to the Canvas.

<Canvas style={{ background: "black" }}>
  <RecoilRoot>
    <directionalLight intensity={1} />
    <ambientLight intensity={0.1} />
    <Suspense fallback={<Loading />}>
      <ArWing />
    </Suspense>
    <Target />
    <Enemies />
    <Lasers />
    <Terrain />
    <LaserController />
  </RecoilRoot>
</Canvas>

Controlling game state updates

So now we can see lasers and enemies in our scene, but they don't do anything. Let's use the power of Recoil combined with React Three Fiber to put everything into motion.

Before we start writing game logic, create a few constant values to store game settings, we might want to change later. This will help with readability and make it easier to tweak the game later on.

// Game settings.
const LASER_RANGE = 100;
const LASER_Z_VELOCITY = 1;
const ENEMY_SPEED = 0.1;
const GROUND_HEIGHT = -50;

Create a new component called GameTimer. This component will be where we keep our main game loop code. It will power the movement of all the lasers and enemies, as well as control hit detection and collisions.

First, with useRecoilState get references to the enemies, lasers, and score. We don't need the ship position for now. We are just going to implement laser firing, and destroying enemies. To do so we will mostly be calling the recoil setter functions to update the game state. The important bits are updating the laser and enemy ship positions, and also detecting lasers or enemies that should be removed from the array. Our Lasers and Enemies components will pick up on any state change we make here and update the Canvas as required.

// A helper function to calculate the distance between two points in 3d space.
// Used to detect lasers intersecting with enemies.
function distance(p1, p2) {
  const a = p2.x - p1.x;
  const b = p2.y - p1.y;
  const c = p2.z - p1.z;

  return Math.sqrt(a * a + b * b + c * c);
}

// This component runs game logic on each frame draw to update game state.
function GameTimer() {
  const [enemies, setEnemies] = useRecoilState(enemyPositionState);
  const [lasers, setLaserPositions] = useRecoilState(laserPositionState);
  const [score, setScore] = useRecoilState(scoreState);

  useFrame(({ mouse }) => {
    // Map through all of the enemies in state. Detect if each enemy is within one unit of a laser if they are set that place in the return array to true.
    // The result will be an array where each index is either a hit enemy or an unhit enemy.
    const hitEnemies = enemies
      ? enemies.map(
          (enemy) =>
            lasers.filter(
              (laser) =>
                lasers.filter((laser) => distance(laser, enemy) < 3).length > 0
            ).length > 0
        )
      : [];

    if (hitEnemies.includes(true) && enemies.length > 0) {
      setScore(score + hitEnemies.filter((hit) => hit).length);
      console.log("hit detected");
    }

    // Move all of the enemies. Remove enemies that have been destroyed, or that have passed the player.
    setEnemies(
      enemies
        .map((enemy) => ({ x: enemy.x, y: enemy.y, z: enemy.z + ENEMY_SPEED }))
        .filter((enemy, idx) => !hitEnemies[idx] && enemy.z < 0)
    );
    // Move the Lasers and remove lasers at end of range or that have hit the ground.
    setLaserPositions(
      lasers
        .map((laser) => ({
          id: laser.id,
          x: laser.x + laser.velocity[0],
          y: laser.y + laser.velocity[1],
          z: laser.z - LASER_Z_VELOCITY,
          velocity: laser.velocity,
        }))
        .filter((laser) => laser.z > -LASER_RANGE && laser.y > GROUND_HEIGHT)
    );
  });
  return null;
}

Finally, add it to the Canvas.

<Canvas style={{ background: "black" }}>
  <RecoilRoot>
    <directionalLight intensity={1} />
    <ambientLight intensity={0.1} />
    <Suspense fallback={<Loading />}>
      <ArWing />
    </Suspense>
    <Target />
    <Enemies />
    <Lasers />
    <Terrain />
    <LaserController />
    <GameTimer />
  </RecoilRoot>
</Canvas>

Let's break down all of the important parts of the GameTimer component. This component doesn't render anything but it encapsulates all of our important game logic.

Detect if any enemies and lasers are intersecting

Before updating the positions of any enemies or lasers, detect which enemies are in the process of being hit by lasers on this frame. Using map we can return an array that indicates true for any enemy that was hit, and false for any enemy that was not hit.

// A helper function to calculate the distance between two points in 3d space.
// Used to detect lasers intersecting with enemies.
function distance(p1, p2) {
  const a = p2.x - p1.x;
  const b = p2.y - p1.y;
  const c = p2.z - p1.z;

  return Math.sqrt(a * a + b * b + c * c);
}
// ...
const hitEnemies = enemies
  ? enemies.map(
      (enemy) =>
        lasers.filter(
          (laser) =>
            lasers.filter((laser) => distance(laser, enemy) < 3).length > 0
        ).length > 0
    )
  : [];

Update the Score for each destroyed enemy

If any values in the hitEnemies array is true, add the number of new hits to the score.

if (hitEnemies.includes(true)) {
  setScore(score + hitEnemies.filter((hit) => hit).length);
  console.log("hit detected");
}

Update the position of the enemies and remove enemies in the destroyed list or that are behind the player.

Move all of the enemies. For now just move them closer to the camera on the z-index. Later we can give them more interesting movement patterns here. Also filter out any enemies that have been destroyed, or that have already moved past the player as indicated by being positioned greater than 0 on the z access.

// Move all of the enemies. Remove enemies that have been destroyed, or passed the player.
setEnemies(
  enemies
    .map((enemy) => ({ x: enemy.x, y: enemy.y, z: enemy.z + ENEMY_SPEED }))
    .filter((enemy, idx) => !hitEnemies[idx] && enemy.z < 0)
);

Update the position of all lasers and remove lasers at the end of their range

And finally, update the laser positions. The lasers reference their initial velocity and increase on the x and y access according to those values, to ensure they continue traveling in the initial direction they were fired. They always move forward a fixed position on the z access though we might tweak that later for gameplay reasons. Also, filter out any lasers that have hit the end of their range, or have hit the ground.

setLaserPositions(
  lasers
    .map((laser) => ({
      id: laser.id,
      x: laser.x + laser.velocity[0],
      y: laser.y + laser.velocity[1],
      z: laser.z - LASER_Z_VELOCITY,
      velocity: laser.velocity,
    }))
    .filter((laser) => laser.z > -LASER_RANGE && laser.y > GROUND_HEIGHT)
);

With this done, you should have a straightforward but playable game matching the demo from the start of the tutorial. We still have a long way to go to make it fun, but with these core pieces in place we are ready to start making things really interesting.

Video Companion to the Tutorial

Since releasing this post I put up a video on YouTube which is designed to be an optional compliment to the content in this tutorial. In the video I summarize some of the main points and provide some extra commentary. Leave a comment in the video if you have any feedback on either it or this post. Thanks!