Rendering the Map

Crossy Road with Three.js ● Chapter 2

In this chapter, we will render the game map. The map consists of multiple rows, each described by metadata. Each row can be a forest, car, or truck lane. We go through each type and define the 3D objects that represent them.

The Different Row Types

The game map consists of multiple rows, each representing a distinct environment or obstacle.

  • The forest is a row of grass and trees.

  • The car lane contains cars.

  • The truck lane contains trucks.

Let’s define the types for the rows in types.ts. The RowType type is a union of the different row types. The Row type is a union of the different row objects. We will go through each row type in detail as we implement it.

src/types.ts
import * as THREE from "three";
export type RowType = "forest" | "car" | "truck";
export type Row =
| {
type: "forest";
trees: { tileIndex: number; height: number }[];
}
| {
type: "car";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
ref?: THREE.Object3D;
}[];
}
| {
type: "truck";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
ref?: THREE.Object3D;
}[];
};
src/types.js
export {};

The Different Row Types

The game map consists of multiple rows, each representing a distinct environment or obstacle.

  • The forest is a row of grass and trees.

  • The car lane contains cars.

  • The truck lane contains trucks.

Let’s define the types for the rows in types.ts. The RowType type is a union of the different row types. The Row type is a union of the different row objects. We will go through each row type in detail as we implement it.

src/types.ts
import * as THREE from "three";
export type RowType = "forest" | "car" | "truck";
export type Row =
| {
type: "forest";
trees: { tileIndex: number; height: number }[];
}
| {
type: "car";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
ref?: THREE.Object3D;
}[];
}
| {
type: "truck";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
ref?: THREE.Object3D;
}[];
};
src/types.js
export {};

Tiles

Each row consists of multiple tiles. The player moves from tile to tile. Trees are also placed on tiles. Cars and trucks do not relate to tiles; they move freely through the lane.

We define in a constants.js constants.ts file how many tiles each row has. In this case, we have 17 tiles per row, going from -8 to 8. The player will start in the middle at tile 0.

src/constants.ts
export const minTileIndex = -8;
export const maxTileIndex = 8;
export const tilesPerRow = maxTileIndex - minTileIndex + 1;
export const tileSize = 42;
src/constants.js
export const minTileIndex = -8;
export const maxTileIndex = 8;
export const tilesPerRow = maxTileIndex - minTileIndex + 1;
export const tileSize = 42;

Adding the Starting Row

In this section, we define some of the components we use to render the map, and we render the initial row.

The Map Component

Let’s create a new component called Map. This file will expose the map’s metadata, the 3D objects representing the map, and functions to extend and reset the map.

Let’s export a Group called map. This container will contain the 3D objects of each row. We will add this group to the scene later.

We also export the initializeMap function, which sets the map’s content. Later, we will extend this function to generate the 3D objects based on the metadata and use it to reset the map.

For now, let’s call the Grass function, which will return another Three.js group. We call the Grass function with the row index. The Grass component will position itself based on this index. We add the returned object to the map.

Now, let’s define the Grass function.

src/components/Map.ts
import * as THREE from "three";
import { Grass } from "./Grass";
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
}

The Grass Function

The Grass function returns the foundation and container of the forest rows, and it is also used for the starting row. It returns a group that contains a flat, wide green box.

The dimensions of this box are determined by the constants tileSize and tilesPerRow. It also has a bit of height, so it sticks out compared to a road, which will be completely flat.

The Grass can serve as the container for the trees in the row. That’s why we wrap the green box into a group so that we can add children later.

We position the group along the y-axis based on the rowIndex. This way, each row will have a different position.

src/components/Grass.ts
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Grass(rowIndex: number) {
const grass = new THREE.Group();
grass.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.BoxGeometry(tilesPerRow * tileSize, tileSize, 3),
new THREE.MeshLambertMaterial({ color: 0xbaf455 })
);
foundation.position.z = 1.5;
grass.add(foundation);
return grass;
}
src/components/Grass.js
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Grass(rowIndex) {
const grass = new THREE.Group();
grass.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.BoxGeometry(tilesPerRow * tileSize, tileSize, 3),
new THREE.MeshLambertMaterial({ color: 0xbaf455 })
);
foundation.position.z = 1.5;
grass.add(foundation);
return grass;
}

Add the Map to the Scene

Let’s add the map to the scene in the main file. Since the map is initially an empty group, we also have to call the initializeMap function to render its content.

Let’s also define the initializeGame function, which will set the map’s content. Later, we will extend this function and use it also to reset the game. Make sure to call this function before we render the scene. Otherwise, an empty map will be rendered.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
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);
initializeGame();
function initializeGame() {
initializeMap();
}
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 { map, initializeMap } from "./components/Map";
import "./style.css";
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
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);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.render(scene, camera);

Adding Threes

It’s time to extend our map with a forest row. We define metadata for the map and render the rows based on it.

Defining the Map Metadata

Let’s define the metadata for the map. The metadata is an array of objects that contain information about each row. Each row object contains a type property that decides if we have a forest row, a car, or a truck lane. The rest of the properties depend on the row type.

For now, let’s hardcode the values for one single row. Later, we will generate the rows dynamically.

The metadata for a forest row has the type forest and a list of trees. Each tree has the following properties:

  • tileIndex: The tile number of the tree within the row. In this case, we have 17 tiles per row, going from -8 to 8
  • height: The height of the crown in units
src/components/Map.ts
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
export const metadata: Row[] = [
{
type: "forest",
trees: [
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
{ tileIndex: 5, height: 50 },
],
},
];
6 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
export const metadata = [
{
type: "forest",
trees: [
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
{ tileIndex: 5, height: 50 },
],
},
];
6 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
}

Rendering Rows by Type

To render the rows, we call the addRows function in the initializeMap function. This function generates 3D objects based on the metadata and adds them to the map container.

The addRows function loops over the metadata and creates a 3D object for each row based on the row type. For the forest type, it calls the Grass function, which returns a Three.js group.

We call the Grass function with the row index. The row index is off by one compared to the array index because the first item in the metadata array will become the second row (after the starting row).

Forest rows have trees. For each item in the trees array, we render a tree. The Tree function will return a 3D object representing the tree. We pass on to this function the tileIndex that we will use to position the tree within the row and its height. We add the trees to the group the Grass function returns.

We add the row to the map. Since we already added the map to the scene, the forest will appear on the screen. But first, we need to define how to render a tree.

src/components/Map.ts
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Tree } from "./Tree";
10 collapsed lines
export const metadata: Row[] = [
{
type: "forest",
trees: [
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
{ tileIndex: 5, height: 50 },
],
},
];
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
});
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
import { Tree } from "./Tree";
10 collapsed lines
export const metadata = [
{
type: "forest",
trees: [
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
{ tileIndex: 5, height: 50 },
],
},
];
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
});
}

The Tree Function

A tree consists of a trunk and a crown. Both are simple boxes. The trunk is placed on top of the ground (lifted along the z-axis by half its height), and the crown is placed on top of the trunk. The crown’s height is based on the height property.

The two meshes are wrapped together in a group positioned along the x-axis based on the tileIndex attribute.

Grab the tree to see it from different angles.

src/components/Tree.ts
import * as THREE from "three";
import { tileSize } from "../constants";
export function Tree(tileIndex: number, height: number) {
const tree = new THREE.Group();
tree.position.x = tileIndex * tileSize;
const trunk = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: 0x4d2926,
flatShading: true,
})
);
trunk.position.z = 10;
tree.add(trunk);
const crown = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, height),
new THREE.MeshLambertMaterial({
color: 0x7aa21d,
flatShading: true,
})
);
crown.position.z = height / 2 + 20;
tree.add(crown);
return tree;
}
src/components/Tree.js
import * as THREE from "three";
import { tileSize } from "../constants";
export function Tree(tileIndex, height) {
const tree = new THREE.Group();
tree.position.x = tileIndex * tileSize;
const trunk = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: 0x4d2926,
flatShading: true,
})
);
trunk.position.z = 10;
tree.add(trunk);
const crown = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, height),
new THREE.MeshLambertMaterial({
color: 0x7aa21d,
flatShading: true,
})
);
crown.position.z = height / 2 + 20;
tree.add(crown);
return tree;
}

Adding Car Lanes

Adding car lanes follows a similar structure to the forest. We define metadata for the vehicles and then map them to 3D objects.

Metadata of a Car Lane

Let’s replace the forest row with a car lane. The car lane will contain a single red car moving to the left.

Each car lane object will have the following properties:

  • direction: A boolean that sets the direction of the vehicles. true means the cars move to the right, false means the vehicles move to the left
  • speed: The speed of the vehicles in units per second
  • vehicles: An array of objects that contain the metadata for each vehicle on the lane.

Each vehicle will have the following properties:

  • initialTileIndex: The tile number of the vehicle’s initial position within the lane. We have 17 tiles per lane, going from -8 to 8.
  • color: The color of the vehicle in hexadecimal format.
src/components/Map.ts
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Tree } from "./Tree";
export const metadata: Row[] = [
{
type: "car",
direction: false,
speed: 1,
vehicles: [{ initialTileIndex: 2, color: 0xff0000 }],
},
];
24 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
});
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
import { Tree } from "./Tree";
export const metadata = [
{
type: "car",
direction: false,
speed: 1,
vehicles: [{ initialTileIndex: 2, color: 0xff0000 }],
},
];
24 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
});
}

Rendering Car Lanes

Let’s extend the addRows function to support car lanes. We add another if block that is very similar to rendering a forest.

In the case of the car type, we call the Road function, which will also return a Three.js group. We also call this function with the row index to position the group.

Then, for each item in the vehicles array, we create a 3D object representing a car with the Car function. We add the cars to the group returned by the Road function.

To the Car function, we pass the initialTileIndex that we will use to position the car within the row, its direction, and its color.

The Road and Car functions are new here. Let’s take a look at them next.

src/components/Map.ts
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
16 collapsed lines
export const metadata: Row[] = [
{
type: "car",
direction: false,
speed: 1,
vehicles: [{ initialTileIndex: 2, color: 0xff0000 }],
},
];
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
});
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
16 collapsed lines
export const metadata = [
{
type: "car",
direction: false,
speed: 1,
vehicles: [{ initialTileIndex: 2, color: 0xff0000 }],
},
];
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
});
}

The Road Component

The Road function returns the foundation and container of the car and truck lanes. It returns a group that contains a gray plane.

Similar to the Grass function, the size of the plane is determined by the constants tileSize and tilesPerRow. Unlike the Grass function, though, the plane doesn’t have any height.

The Road will serve as the container for the cars and trucks in the row. That’s why we wrap the plane into a group so that we can add children later.

src/components/Road.ts
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Road(rowIndex: number) {
const road = new THREE.Group();
road.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.PlaneGeometry(tilesPerRow * tileSize, tileSize),
new THREE.MeshLambertMaterial({ color: 0x454a59 })
);
road.add(foundation);
return road;
}
src/components/Road.js
import * as THREE from "three";
import { tilesPerRow, tileSize } from "../constants";
export function Road(rowIndex) {
const road = new THREE.Group();
road.position.y = rowIndex * tileSize;
const foundation = new THREE.Mesh(
new THREE.PlaneGeometry(tilesPerRow * tileSize, tileSize),
new THREE.MeshLambertMaterial({ color: 0x454a59 })
);
road.add(foundation);
return road;
}

The Car Function

The Car function returns a simplified 3D model of a car. It consists of a box for the body, a smaller box for the top, and two Wheel meshes for the wheels.

We group all these elements together, position them based on the initialTileIndex property, and turn them based on the direction property. If the car goes to the left, we rotate it 180 degrees, which is equivalent to Math.PI in radians.

Grab the car to see it from different angles.

src/components/Car.ts
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Car(
initialTileIndex: number,
direction: boolean,
color: THREE.ColorRepresentation
) {
const car = new THREE.Group();
car.position.x = initialTileIndex * tileSize;
if (!direction) car.rotation.z = Math.PI;
const main = new THREE.Mesh(
new THREE.BoxGeometry(60, 30, 15),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
main.position.z = 12;
car.add(main);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(33, 24, 12),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
cabin.position.x = -6;
cabin.position.z = 25.5;
car.add(cabin);
const frontWheel = Wheel(18);
car.add(frontWheel);
const backWheel = Wheel(-18);
car.add(backWheel);
return car;
}
src/components/Car.js
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Car(initialTileIndex, direction, color) {
const car = new THREE.Group();
car.position.x = initialTileIndex * tileSize;
if (!direction) car.rotation.z = Math.PI;
const main = new THREE.Mesh(
new THREE.BoxGeometry(60, 30, 15),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
main.position.z = 12;
car.add(main);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(33, 24, 12),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
cabin.position.x = -6;
cabin.position.z = 25.5;
car.add(cabin);
const frontWheel = Wheel(18);
car.add(frontWheel);
const backWheel = Wheel(-18);
car.add(backWheel);
return car;
}

The Wheel Function

To continue with our boxy theme, the Wheel function returns a simple box with a dark color. Because we never see the cars from under, we don’t need to separate the wheels into left and right. We can use one long box for the front wheels and another for the back wheels.

src/components/Wheel.ts
import * as THREE from "three";
export function Wheel(x: number) {
const wheel = new THREE.Mesh(
new THREE.BoxGeometry(12, 33, 12),
new THREE.MeshLambertMaterial({
color: 0x333333,
flatShading: true,
})
);
wheel.position.x = x;
wheel.position.z = 6;
return wheel;
}
src/components/Wheel.js
import * as THREE from "three";
export function Wheel(x) {
const wheel = new THREE.Mesh(
new THREE.BoxGeometry(12, 33, 12),
new THREE.MeshLambertMaterial({
color: 0x333333,
flatShading: true,
})
);
wheel.position.x = x;
wheel.position.z = 6;
return wheel;
}

Adding Truck Lanes

Truck lanes are almost the same as car lanes. The metadata and the components follow the same structure. Except this time, we render trucks instead of cars.

Metadata of a Truck Lane

Let’s replace our car lane with a truck lane. The metadata structure is the same. We define the direction and speed properties and set a vehicles array. The only difference is that the type of the row is truck this time.

src/components/Map.ts
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
export const metadata: Row[] = [
{
type: "truck",
direction: true,
speed: 0,
vehicles: [{ initialTileIndex: -4, color: 0x00ff00 }],
},
];
39 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
});
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
export const metadata = [
{
type: "truck",
direction: true,
speed: 0,
vehicles: [{ initialTileIndex: -4, color: 0x00ff00 }],
},
];
39 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
});
}

Rendering Truck Lanes

Let’s extend our addRows function again to support truck lanes. We add another if block that is almost the same as the one rendering a car lane.

We use the same Road function again as a container and foundation, and this time, we map the vehicles to the result of the Truck function.

src/components/Map.ts
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
16 collapsed lines
export const metadata: Row[] = [
{
type: "truck",
direction: true,
speed: 0,
vehicles: [{ initialTileIndex: -4, color: 0x00ff00 }],
},
];
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
if (rowData.type === "truck") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(truck);
});
map.add(row);
}
});
}
src/components/Map.js
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
16 collapsed lines
export const metadata = [
{
type: "truck",
direction: true,
speed: 0,
vehicles: [{ initialTileIndex: -4, color: 0x00ff00 }],
},
];
export const map = new THREE.Group();
export function initializeMap() {
const grass = Grass(0);
map.add(grass);
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
if (rowData.type === "truck") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(truck);
});
map.add(row);
}
});
}

The Truck Function

A truck is also built up from simple boxes. We have a long gray box for the cargo and a smaller box for the cabin. The cabin’s color depends on the color property.

We position the truck on the lane using the initialTilePosition property and turn it in the right direction using the direction property.

We use the same Wheel component as the car did. This time we use three of them.

Grab the truck to see it from different angles.

src/components/Truck.ts
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Truck(
initialTileIndex: number,
direction: boolean,
color: THREE.ColorRepresentation
) {
const truck = new THREE.Group();
truck.position.x = initialTileIndex * tileSize;
if (!direction) truck.rotation.z = Math.PI;
const cargo = new THREE.Mesh(
new THREE.BoxGeometry(70, 35, 35),
new THREE.MeshLambertMaterial({
color: 0xb4c6fc,
flatShading: true,
})
);
cargo.position.x = -15;
cargo.position.z = 25;
truck.add(cargo);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, 30),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
cabin.position.x = 35;
cabin.position.z = 20;
truck.add(cabin);
const frontWheel = Wheel(37);
truck.add(frontWheel);
const middleWheel = Wheel(5);
truck.add(middleWheel);
const backWheel = Wheel(-35);
truck.add(backWheel);
return truck;
}
src/components/Truck.js
import * as THREE from "three";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
export function Truck(initialTileIndex, direction, color) {
const truck = new THREE.Group();
truck.position.x = initialTileIndex * tileSize;
if (!direction) truck.rotation.z = Math.PI;
const cargo = new THREE.Mesh(
new THREE.BoxGeometry(70, 35, 35),
new THREE.MeshLambertMaterial({
color: 0xb4c6fc,
flatShading: true,
})
);
cargo.position.x = -15;
cargo.position.z = 25;
truck.add(cargo);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, 30),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
cabin.position.x = 35;
cabin.position.z = 20;
truck.add(cabin);
const frontWheel = Wheel(37);
truck.add(frontWheel);
const middleWheel = Wheel(5);
truck.add(middleWheel);
const backWheel = Wheel(-35);
truck.add(backWheel);
return truck;
}

Adding Rows Before the Player

Now, we can render a map with several rows based on metadata. For aesthetic reasons, let’s add a couple empty rows before the player.

Let’s add a few more empty grass objects to the map before the player’s position. In the initializeMap function, instead of rendering one Grass object for the starting row, we render five of them. Each has its own negative row index.

src/components/Map.ts
47 collapsed lines
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
export const metadata: Row[] = [
{
type: "car",
direction: false,
speed: 188,
vehicles: [
{ initialTileIndex: -4, color: 0xbdb638 },
{ initialTileIndex: -1, color: 0x78b14b },
{ initialTileIndex: 4, color: 0xa52523 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -5, height: 50 },
{ tileIndex: 0, height: 30 },
{ tileIndex: 3, height: 50 },
],
},
{
type: "truck",
direction: true,
speed: 125,
vehicles: [
{ initialTileIndex: -4, color: 0x78b14b },
{ initialTileIndex: 0, color: 0xbdb638 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -8, height: 30 },
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
],
},
];
export const map = new THREE.Group();
export function initializeMap() {
for (let rowIndex = 0; rowIndex > -5; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
46 collapsed lines
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
if (rowData.type === "truck") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(truck);
});
map.add(row);
}
});
}
src/components/Map.js
46 collapsed lines
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
export const metadata = [
{
type: "car",
direction: false,
speed: 188,
vehicles: [
{ initialTileIndex: -4, color: 0xbdb638 },
{ initialTileIndex: -1, color: 0x78b14b },
{ initialTileIndex: 4, color: 0xa52523 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -5, height: 50 },
{ tileIndex: 0, height: 30 },
{ tileIndex: 3, height: 50 },
],
},
{
type: "truck",
direction: true,
speed: 125,
vehicles: [
{ initialTileIndex: -4, color: 0x78b14b },
{ initialTileIndex: 0, color: 0xbdb638 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -8, height: 30 },
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
],
},
];
export const map = new THREE.Group();
export function initializeMap() {
for (let rowIndex = 0; rowIndex > -5; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
46 collapsed lines
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
if (rowData.type === "truck") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(truck);
});
map.add(row);
}
});
}

Metadata of the Following Chapters

The metadata for the next few chapters and the demo above is as follows.

src/components/Map.ts
7 collapsed lines
import type { Row } from "../types";
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
export const metadata: Row[] = [
{
type: "car",
direction: false,
speed: 188,
vehicles: [
{ initialTileIndex: -4, color: 0xbdb638 },
{ initialTileIndex: -1, color: 0x78b14b },
{ initialTileIndex: 4, color: 0xa52523 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -5, height: 50 },
{ tileIndex: 0, height: 30 },
{ tileIndex: 3, height: 50 },
],
},
{
type: "truck",
direction: true,
speed: 125,
vehicles: [
{ initialTileIndex: -4, color: 0x78b14b },
{ initialTileIndex: 0, color: 0xbdb638 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -8, height: 30 },
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
],
},
];
56 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
for (let rowIndex = 0; rowIndex > -5; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
if (rowData.type === "truck") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(truck);
});
map.add(row);
}
});
}
src/components/Map.js
6 collapsed lines
import * as THREE from "three";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
export const metadata = [
{
type: "car",
direction: false,
speed: 188,
vehicles: [
{ initialTileIndex: -4, color: 0xbdb638 },
{ initialTileIndex: -1, color: 0x78b14b },
{ initialTileIndex: 4, color: 0xa52523 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -5, height: 50 },
{ tileIndex: 0, height: 30 },
{ tileIndex: 3, height: 50 },
],
},
{
type: "truck",
direction: true,
speed: 125,
vehicles: [
{ initialTileIndex: -4, color: 0x78b14b },
{ initialTileIndex: 0, color: 0xbdb638 },
],
},
{
type: "forest",
trees: [
{ tileIndex: -8, height: 30 },
{ tileIndex: -3, height: 50 },
{ tileIndex: 2, height: 30 },
],
},
];
56 collapsed lines
export const map = new THREE.Group();
export function initializeMap() {
for (let rowIndex = 0; rowIndex > -5; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
export function addRows() {
metadata.forEach((rowData, index) => {
const rowIndex = index + 1;
if (rowData.type === "forest") {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === "car") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(car);
});
map.add(row);
}
if (rowData.type === "truck") {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
row.add(truck);
});
map.add(row);
}
});
}
Previous:
Setting up the Game
Next:
Adding Shadows