Setting up the Game

Crossy Road with React Three Fiber ● Chapter 1

In this chapter, we set up the drawing canvas, the camera, and the lights to render a box. We use an orthographic camera with both ambient and directional lights. Then, we frame the scene to ensure we always see the same content regardless of the screen size.

Initializing the Project

There are many ways to set up a project with React Three Fiber. I recommend using Vite. It’s a fast and opinionated build tool that makes it easy to get started.

  • Run npm create vite to create a new project. When asked for a framework, pick React. Choose TypeScript or JavaScript as you prefer. This tutorial guides you through both languages (use the language selector above).

  • Navigate to the project folder Vite created for you.

  • At the time of writing this article, Vite still uses React 18. Meanwhile, React 19 is out, and the latest version of React Three Fiber is only compatible with React 19. So let’s update react and react-dom.

  • Install the further dependencies. We are going to use Three.js, React Three Fiber, Drei and zustand.

  • Finally, run npm run dev to spin up the development server.

Terminal window
# Create app
npm create vite my-crossy-road-game
# Select React as framework
# Select TypeScript or JavaScript
# Navigate to the project
cd my-crossy-road-game
# Update react and react-dom
npm install react@latest react-dom@latest
# Install dependencies
npm install three @react-three/fiber @react-three/drei zustand
# If you are using TypeScript:
# npm install @types/react@latest @types/react-dom@latest
# npm install @types/three
# Start development server
npm run dev

Reducing ESlint Noise

As we go through the tutorial, we will have some variables that we don’t use right away. ESlint, however, doesn’t like unused variables, and it marks them as errors. Let’s reduce the noise by downgrading the severity of the no-unused-vars rule to a warning.

eslint.config.js
5 collapsed lines
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
10 collapsed lines
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": "warn",
},
}
);

Turning off ESlint Rules

Vite set up ESlint for us, which is great. However, it also set up some rules that we don’t want for this project. Let’s turn them off.

  • react/prop-types: This rule makes it mandatory to define the types of the props. If you care about types, follow the TypeScript version of this tutorial.
  • react/no-unknown-property: This rule ensures that we don’t use unknown properties in JSX. It doesn’t play nicely with React Three Fiber, so we need to turn it off.
  • no-unused-vars: This rule ensures that we don’t have unused variables. As we go through the tutorial, we will have some variables that we don’t use right away. We reduce the noise by downgrading the severity of this rule to a warning.
eslint.config.js
5 collapsed lines
import js from "@eslint/js";
import globals from "globals";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{ ignores: ["dist"] },
{
16 collapsed lines
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module",
},
},
settings: { react: { version: "18.3" } },
plugins: {
react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs["jsx-runtime"].rules,
...reactHooks.configs.recommended.rules,
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"react/prop-types": "off",
"react/no-unknown-property": "off",
"no-unused-vars": "warn",
},
},
];

The Root Component

The Game Component

Let’s create a new file, src/Game.tsx. This component will be the root of our game.

Let’s create a new component, src/Game.jsx. This will be the root of our game.

The Scene component will contain the drawing canvas, the camera, and the lights. We pass on the Player component as its child, which will render a box. Later, we will add the Map component, including the trees, cars, and trucks. This component is also where the score indicator and the controls come later.

src/Game.tsx
import { Scene } from "./components/Scene";
import { Player } from "./components/Player";
export default function Game() {
return (
<Scene>
<Player />
</Scene>
);
}
src/Game.jsx
import { Scene } from "./components/Scene";
import { Player } from "./components/Player";
export default function Game() {
return (
<Scene>
<Player />
</Scene>
);
}

Setting the Game Component as the Root

To use the new Game component as our root, we need to replace the original App component in the src/main.jsx src/main.tsx file.

This will give you an error for now because we didn’t implement the Scene and Player components.

Now that we replaced the App component, we can delete the original App.tsx, App.css, and the assets folder.

Now that we replaced the App component, we can delete the original App.jsx, App.css, and the assets folder.

src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import Game from "./Game";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Game />
</StrictMode>
);
src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import Game from "./Game";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
<Game />
</StrictMode>
);

Filling the Screen

We can also get rid of most things in the CSS file. All we need is that the body element and the root div fills the whole screen.

Now, let’s look into the different camera and light options.

src/index.css
body {
margin: 0;
display: flex;
min-height: 100vh;
}
#root {
width: 100%;
}

Orthographic vs Perspective Camera

There are two main camera options: the perspective camera (left) and the orthographic camera (right). The perspective camera is the default camera in Three.js. It is the most common camera type and is used to create perspective projections, which make things further away appear to be smaller.

The orthographic camera creates parallel projections, which means that objects are the same size regardless of their distance from the camera. We use an orthographic camera to give our images a more arcade, boxy look. Grab the scenes below to see how these projections work.

Camera Position and Target

We use a coordinate system in which the ground is the xy-plane, and the z-axis points up. This way, the player can move left and right along the x-axis, forward and backward along the y-axis, and when it jumps, it goes up along the z-axis.

The camera is positioned to the right along the x-axis, behind the player along the y-axis, and above the ground. The camera looks at the origin of the coordinate system, to the 0,0,0 coordinate, where the player is initially.

Ambient and Directional Light

There are many types of lights in Three.js. We use an ambient light and a directional light. Turn around the scenes below to see how the light affects the objects.

The ambient light brightens the entire scene. It doesn’t have a specific position or direction. You can think of it as the light on a cloudy day when it is bright, but there are no shadows. The ambient light is used to simulate indirect lighting. The downside is that, as a result, every side of our objects is equally bright, and every side of the boxes in the scene has the same color. We don’t see the edges of the objects.

The directional light has a position and a target. The position is misleading, though. It doesn’t shine from one point in every direction like a point light. The position and target are used to determine its direction. You can think of it as the sun, which shines from very far away, and all the light rays are in parallel. The directional light can cast shadows. Its downside is that the sides of the object that don’t receive light are completely black.

That’s why we are combining the two lights. The ambient light gives the whole scene a baseline brightness, and the directional light will highlight some sides of our shapes.

Defining the Scene

After reviewing the different camera and light options, let’s put them together in the Scene component. We set up the canvas with an orthographic camera and lights.

We use the Canvas component from @react-three/fiber. This component will contain every 3D object on the scene, so it has a children prop.

We set the orthographic prop to true to use an orthographic camera and the camera prop to define the camera’s position and orientation. The camera props require vectors or coordinates that are defined by the x, y, and z values.

The up prop sets the camera’s up vector. We set it to [0, 0, 1] to make the z-axis the up vector. The position prop sets the camera’s position. We move the camera to the right along the x-axis, backward along the y-axis, and up along the z-axis.

We also add the lights. We can use React Three Fiber-specific elements within the Canvas element. We add the ambientLight and directionalLight components to add lights to the scene. We position the directional light to the left along the x-axis, backward along the y-axis, and up along the z-axis.

src/components/Scene.tsx
import { Canvas } from "@react-three/fiber";
export const Scene = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<Canvas
orthographic={true}
camera={{
up: [0, 0, 1],
position: [300, -300, 300],
}}
>
<ambientLight />
<directionalLight position={[-100, -100, 200]} />
{children}
</Canvas>
);
};
src/components/Scene.jsx
import { Canvas } from "@react-three/fiber";
export const Scene = ({ children }) => {
return (
<Canvas
orthographic={true}
camera={{
up: [0, 0, 1],
position: [300, -300, 300],
}}
>
<ambientLight />
<directionalLight position={[-100, -100, 200]} />
{children}
</Canvas>
);
};

Drawing the Player

We added many things to the scene already, but we still don’t have any visible objects. Let’s define the player as a simple box.

The Player

Then, let’s define the player. In the Game component, we passed on the Player component as a child to the Scene component.

Initially, the player will be a simple box. To draw a 3D box, we define a geometry and a material. The geometry defines the object’s shape, and the material defines its appearance.

Here, we use boxGeometry to define a box. The boxGeometry takes three arguments: the width, depth, and height of the box along the x, y, and z-axes.

There are different types of materials in Three.js. The main difference between them is how they react to light. We use the meshLambertMaterial, a simple material that responds to light. We set the color prop to white with a hexadecimal value.

We wrap the geometry and the material into a mesh. A mesh is an object that we can add to the scene. We can also position a mesh. We set the box position with the x, y, and z positions. In the case of a box, these set the center position. Here, we elevate the player above the ground by half its height. This way, the bottom of the box is on the ground.

src/components/Player.tsx
export function Player() {
return (
<mesh position={[0, 0, 10]}>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
);
}
src/components/Player.jsx
export function Player() {
return (
<mesh position={[0, 0, 10]}>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
);
}

Framing the Scene

By default, one unit in the scene matches one pixel on the screen (if the shape faces the camera and we use an orthographic camera). This is even true when we resize the screen. If we enlarge the browser window, the size of the objects within the canvas doesn’t change. Instead, the camera exposes more of the scene.

This is not what we want. We want to show the same content regardless of the screen size. The close surrounding of the player. Resize this screen to see the difference between the default and the desired behavior.

Using Bounds

We frame the scene using the Bounds component from @react-three/drei. This calculates a boundary box and frames the scene according to the margin prop. We want to frame the player, so we wrap it in the Bounds component.

This component calculates the size of its children - this time, the player - multiplies it by the margin prop, and that will be the size of the visible area. If the margin is 1, the child components fill the whole canvas as much as possible (while keeping their proportion). If the margin is 1.2, then there’s a 20% margin around them. If the margin is 10, like in this case, the visible area is ten times the size of the child components. It then sets the camera target to the center of the bounding box.

src/components/Player.tsx
import { Bounds } from "@react-three/drei";
export function Player() {
return (
<Bounds fit clip observe margin={10}>
<mesh position={[0, 0, 10]}>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
export function Player() {
return (
<Bounds fit clip observe margin={10}>
<mesh position={[0, 0, 10]}>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
</Bounds>
);
}
Tutorial:
Crossy Road with React Three Fiber
Next:
Rendering the Map