Setting up the Game

Crossy Road with Three.js ● 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.

Initializing the Project

There are many ways to set up a project with Three.js. 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. Pick Vanilla for a project without any framework when asked for a framework. Choose TypeScript or JavaScript as you prefer. This tutorial guides you through both languages (use the language selector above).

  • Navigate to the project folder and install Three.js with npm install three. If you are using TypeScript, make sure to also install the types for Three.js.

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

Terminal window
# Create app
npm create vite my-crossy-road-game
# Select TypeScript or JavaScript
# Navigate to the project
cd my-crossy-road-game
# Install dependencies
npm install three
# If you are using TypeScript:
# npm install @types/three
# Start development server
npm run dev

The HTML File

The entry point of this project is the intex.html file in the root folder. Let’s replace the div element with a canvas element with the id game. This will be the drawing canvas that Three.js will use to render the scene.

This file has a script tag that points to the main TypeScript file. Let’s look into that next.

This file has a script tag that points to the main JavaScript file. Let’s look into that next.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<canvas class="game"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<canvas class="game"></canvas>
<script type="module" src="/src/main.js"></script>
</body>
</html>

The Main File

Vite created src/main.ts as the main TypeScript file of our project. Let’s replace the content of this file.

Vite created src/main.js as the main JavaScript file of our project. Let’s replace the content of this file.

This file is the root of our game. Here, we define a Three.js Scene that will contain all the 3D elements, including the player that we are going to define soon.

The scene also includes a Camera that we use together with the Renderer to render a static frame of the scene. We define these in the next steps.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { player } from "./components/Player";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
const camera = Camera();
scene.add(camera);
const renderer = Renderer();
renderer.render(scene, camera);
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { player } from "./components/Player";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
const camera = Camera();
scene.add(camera);
const renderer = Renderer();
renderer.render(scene, camera);

Cleaning up the Initial Project

We can get rid of most things in style.css. The only thing we need for now is to remove the default margin around the body element.

We can also remove the javascript.svg and the counter.js files from the src folder.

We can also remove the typescript.svg and the counter.ts files from the src folder.

src/style.css
body {
margin: 0;
display: flex;
}

The Renderer

Let’s create a new folder called components. This folder will mainly contain files that expose Three.js objects we can add to the scene. Some files will also expose a state or additional functions that modify the exposed Three.js object or the state.

Then, let’s create a file for the renderer. The Renderer function creates a Three.js renderer that we use to render the scene. I’m using capital letters for functions that return a Three.js object.

Here, we get the canvas element we defined in the HTML file and use it as the drawing context. We also set a couple of other parameters: we make the background of the 3D scene transparent with the alpha flag, turn on antialiasing, set the pixel ratio, and set the size of the canvas to fill the entire screen.

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

src/components/Renderer.ts
import * as THREE from "three";
export function Renderer() {
const canvas = document.querySelector("canvas.game");
if (!canvas) throw new Error("Canvas not found");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
return renderer;
}
src/components/Renderer.js
import * as THREE from "three";
export function Renderer() {
const canvas = document.querySelector("canvas.game");
if (!canvas) throw new Error("Canvas not found");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
return renderer;
}

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.

Setting up the Camera

With all this theory covered, let’s define an orthographic camera.

Let’s create a file for our camera. The Camera function returns an orthographic camera that we use to render the scene.

To define a camera, we need to define a camera frustum. This will define how to project the 3D elements to the screen. In the case of an orthographic camera, we define a box. Everything on the scene within this box will be projected onto the screen, and everything outside will be off-screen.

Drag the demo below to see how it works. Here, the green dot represents the camera’s position, and the line going to the player shows that it’s looking toward the player.

The gray box shows the camera frustum. The green rectangle shows the intersection of the box with the ground to make it easier to see what part of the scene is part of the camera frustum.

We define the frustum by the distances from the camera’s position to each side of the box. The near, far, left, right, top, and bottom values represent how far each side of the frustum box is from the camera position along the different axes from the camera’s perspective.

In this function, we set up the camera frustum to fill the browser window, and the width or height is 300 units, depending on the aspect ratio. If the width is larger than the height, the height is 300 units; if the height is larger, the width is 300. The demo below shows the camera frustum in case the width and height are both 300.

We set the up property to (0, 0, 1) to make the z-axis the up vector. Then, we also set the camera’s position. We move the camera to the right along the x-axis, backward behind the player along the y-axis, and above the ground along the z-axis. Then, we turn the camera to look at the origin (0, 0, 0).

src/components/Camera.ts
import * as THREE from "three";
export function Camera() {
const size = 300;
const viewRatio = window.innerWidth / window.innerHeight;
const width = viewRatio < 1 ? size : size * viewRatio;
const height = viewRatio < 1 ? size / viewRatio : size;
const camera = new THREE.OrthographicCamera(
width / -2, // left
width / 2, // right
height / 2, // top
height / -2, // bottom
100, // near
900 // far
);
camera.up.set(0, 0, 1);
camera.position.set(300, -300, 300);
camera.lookAt(0, 0, 0);
return camera;
}
src/components/Camera.js
import * as THREE from "three";
export function Camera() {
const size = 300;
const viewRatio = window.innerWidth / window.innerHeight;
const width = viewRatio < 1 ? size : size * viewRatio;
const height = viewRatio < 1 ? size / viewRatio : size;
const camera = new THREE.OrthographicCamera(
width / -2, // left
width / 2, // right
height / 2, // top
height / -2, // bottom
100, // near
900 // far
);
camera.up.set(0, 0, 1);
camera.position.set(300, -300, 300);
camera.lookAt(0, 0, 0);
return camera;
}

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.

Adding the Lights to the Scene

After seeing how they look, let’s add an ambient and directional light to the scene in our main file. We position the directional light to the left along the x-axis, backward along the y-axis, and up along the z-axis.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { player } from "./components/Player";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight();
dirLight.position.set(-100, -100, 200);
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
const renderer = Renderer();
renderer.render(scene, camera);
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { player } from "./components/Player";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight();
dirLight.position.set(-100, -100, 200);
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
const renderer = Renderer();
renderer.render(scene, camera);

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.

We added the player to the scene in the main file. Let’s see how we create the player. In this file, we write a function that creates the 3D object and export a property containing the player instance. The player is a singleton. We only have one player object in the whole game, and every other file can access it through this export.

Initially, the player will be a simple box. To draw a 3D object, 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 property to white as the color of the box.

With the geometry and the material, we define a Mesh. A mesh is an object that we can add to the scene. We can also position a mesh. We can set the box position with the x, y, and z properties. 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.ts
import * as THREE from "three";
export const player = Player();
function Player() {
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z = 10;
return body;
}
src/components/Player.js
import * as THREE from "three";
export const player = Player();
function Player() {
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z = 10;
return body;
}
Tutorial:
Crossy Road with Three.js
Next:
Rendering the Map