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.
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 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.
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; }[]; } | { type: "truck"; direction: boolean; speed: number; vehicles: { initialTileIndex: number; color: THREE.ColorRepresentation; }[]; };
export {};
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.
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; }[]; } | { type: "truck"; direction: boolean; speed: number; vehicles: { initialTileIndex: number; color: THREE.ColorRepresentation; }[]; };
export {};
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.
export const minTileIndex = -8;export const maxTileIndex = 8;export const tilesPerRow = maxTileIndex - minTileIndex + 1;export const tileSize = 42;
export const minTileIndex = -8;export const maxTileIndex = 8;export const tilesPerRow = maxTileIndex - minTileIndex + 1;export const tileSize = 42;
In this section, we define some of the components we use to render the map, and we render the initial row.
Let’s create a new component called Map
. This component will contain the 3D objects for every rows.
For now, let’s add a single Grass
component as the starting row. To the Grass
component we pass on the rowIndex
. The Grass
component will position itself based on this index.
We are going to extend this component with all the other rows, so let’s wrap the returned component into a Fragment
element (using shorthand <>
and </>
).
Now, let’s define the Grass
component.
import { Grass } from "./Grass";
export function Map() { return ( <> <Grass rowIndex={0} /> </> );}
import { Grass } from "./Grass";
export function Map() { return ( <> <Grass rowIndex={0} /> </> );}
The grass component is the foundation and container of the forest
rows, and it is also used for the starting row. Its content is 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 the Road
component, which will be completely flat.
The Grass
component can serve as the container for the trees in the row. It has a children
prop. The Forest
component will pass in the trees as children.
We wrap the green box and the children into a group
element. The group
element is a container that can hold multiple 3D elements and apply transformations to all of them at once. We position it along the y-axis based on the rowIndex
. This way, each row will have a different position.
import { tilesPerRow, tileSize } from "../constants";
type Props = { rowIndex: number; children?: React.ReactNode;};
export function Grass({ rowIndex, children }: Props) { return ( <group position-y={rowIndex * tileSize}> <mesh> <boxGeometry args={[tilesPerRow * tileSize, tileSize, 3]} /> <meshLambertMaterial color={0xbaf455} flatShading /> </mesh> {children} </group> );}
import { tilesPerRow, tileSize } from "../constants";
export function Grass({ rowIndex, children }) { return ( <group position-y={rowIndex * tileSize}> <mesh> <boxGeometry args={[tilesPerRow * tileSize, tileSize, 3]} /> <meshLambertMaterial color={0xbaf455} flatShading /> </mesh> {children} </group> );}
Let’s add a new Map
component to the scene in the Game
component. This will render the starting row.
Now, let’s add more rows to the map.
import { Scene } from "./components/Scene";import { Player } from "./components/Player";import { Map } from "./components/Map";
export default function Game() { return ( <Scene> <Player /> <Map /> </Scene> );}
import { Scene } from "./components/Scene";import { Player } from "./components/Player";import { Map } from "./components/Map";
export default function Game() { return ( <Scene> <Player /> <Map /> </Scene> );}
It’s time to extend our map with a forest row. We define metadata for the map and render the rows based on it.
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 8height
: The height of the crown in unitsimport type { Row } from "./types";
export const rows: Row[] = [ { type: "forest", trees: [ { tileIndex: -3, height: 50 }, { tileIndex: 2, height: 30 }, { tileIndex: 5, height: 50 }, ], },];
export const rows = [ { type: "forest", trees: [ { tileIndex: -3, height: 50 }, { tileIndex: 2, height: 30 }, { tileIndex: 5, height: 50 }, ], },];
Let’s extend the Map
component to render the rows based on the metadata. We import the metadata and map each row to a separate Row
component.
Note that the rowIndex
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).
Now, let’s define the Row
component.
import { rows } from "../metadata";import { Grass } from "./Grass";import { Row } from "./Row";
export function Map() { return ( <> <Grass rowIndex={0} />
{rows.map((rowData, index) => ( <Row key={index} rowIndex={index + 1} rowData={rowData} /> ))} </> );}
import { rows } from "../metadata";import { Grass } from "./Grass";import { Row } from "./Row";
export function Map() { return ( <> <Grass rowIndex={0} />
{rows.map((rowData, index) => ( <Row key={index} rowIndex={index + 1} rowData={rowData} /> ))} </> );}
The Row
component is essentially a switch case that renders the correct row based on the type
property of the row. We only support the forest
type for now, but we will extend this file later to support car and truck lanes.
import type { Row } from "../types";import { Forest } from "./Forest";
type Props = { rowIndex: number; rowData: Row;};
export function Row({ rowIndex, rowData }: Props) { switch (rowData.type) { case "forest": { return <Forest rowIndex={rowIndex} rowData={rowData} />; } }}
import { Forest } from "./Forest";
export function Row({ rowIndex, rowData }) { switch (rowData.type) { case "forest": { return <Forest rowIndex={rowIndex} rowData={rowData} />; } }}
The Forest
component contains the row’s foundation, a Grass
component, and the trees in the row.
The Grass
component can receive children. We map trees’ metadata to Tree
components and pass them on as children to the Grass
component. Each tree gets its tileIndex
, which will be used for positioning the tree within the row, and its height
.
Now, let’s see how to render a tree.
import type { Row } from "../types";import { Grass } from "./Grass";import { Tree } from "./Tree";
type Props = { rowIndex: number; rowData: Extract<Row, { type: "forest" }>;};
export function Forest({ rowIndex, rowData }: Props) { return ( <Grass rowIndex={rowIndex}> {rowData.trees.map((tree, index) => ( <Tree key={index} tileIndex={tree.tileIndex} height={tree.height} /> ))} </Grass> );}
import { Grass } from "./Grass";import { Tree } from "./Tree";
export function Forest({ rowIndex, rowData }) { return ( <Grass rowIndex={rowIndex}> {rowData.trees.map((tree, index) => ( <Tree key={index} tileIndex={tree.tileIndex} height={tree.height} /> ))} </Grass> );}
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.
import { tileSize } from "../constants";
type Props = { tileIndex: number; height: number;};
export function Tree({ tileIndex, height }: Props) { return ( <group position-x={tileIndex * tileSize}> <mesh position-z={height / 2 + 20}> <boxGeometry args={[30, 30, height]} /> <meshLambertMaterial color={0x7aa21d} flatShading /> </mesh> <mesh position-z={10}> <boxGeometry args={[15, 15, 20]} /> <meshLambertMaterial color={0x4d2926} flatShading /> </mesh> </group> );}
import { tileSize } from "../constants";
export function Tree({ tileIndex, height }) { return ( <group position-x={tileIndex * tileSize}> <mesh position-z={height / 2 + 20}> <boxGeometry args={[30, 30, height]} /> <meshLambertMaterial color={0x7aa21d} flatShading /> </mesh> <mesh position-z={10}> <boxGeometry args={[15, 15, 20]} /> <meshLambertMaterial color={0x4d2926} flatShading /> </mesh> </group> );}
Adding car lanes follows a similar structure to the forest. We define metadata for the vehicles and then map them to 3D objects.
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 leftspeed
: The speed of the vehicles in units per secondvehicles
: 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.import type { Row } from "./types";
export const rows: Row[] = [ { type: "car", direction: false, speed: 1, vehicles: [{ initialTileIndex: 2, color: 0xff0000 }], },];
export const rows = [ { type: "car", direction: false, speed: 1, vehicles: [{ initialTileIndex: 2, color: 0xff0000 }], },];
Let’s extend the Row
Component with support for car lanes. If the type of a row is car
we map it to a CarLane
component.
import type { Row } from "../types";import { Forest } from "./Forest";import { CarLane } from "./CarLane";
type Props = { rowIndex: number; rowData: Row;};
export function Row({ rowIndex, rowData }: Props) { switch (rowData.type) { case "forest": { return <Forest rowIndex={rowIndex} rowData={rowData} />; } case "car": { return <CarLane rowIndex={rowIndex} rowData={rowData} />; } }}
import { Forest } from "./Forest";import { CarLane } from "./CarLane";
export function Row({ rowIndex, rowData }) { switch (rowData.type) { case "forest": { return <Forest rowIndex={rowIndex} rowData={rowData} />; } case "car": { return <CarLane rowIndex={rowIndex} rowData={rowData} />; } }}
The CarLane
component renders the cars on the road. It has a similar structure to the Forest
component.
It receives a rowData
object as a prop, which contains the cars to be rendered. It wraps the cars in a Road
component and maps over the rowData.vehicles
array to render each car.
The Road
and Car
components are new here. Let’s take a look at them next.
import type { Row } from "../types";import { Road } from "./Road";import { Car } from "./Car";
type Props = { rowIndex: number; rowData: Extract<Row, { type: "car" }>;};
export function CarLane({ rowIndex, rowData }: Props) { return ( <Road rowIndex={rowIndex}> {rowData.vehicles.map((vehicle, index) => ( <Car key={index} rowIndex={rowIndex} initialTileIndex={vehicle.initialTileIndex} direction={rowData.direction} speed={rowData.speed} color={vehicle.color} /> ))} </Road> );}
import { Road } from "./Road";import { Car } from "./Car";
export function CarLane({ rowIndex, rowData }) { return ( <Road rowIndex={rowIndex}> {rowData.vehicles.map((vehicle, index) => ( <Car key={index} rowIndex={rowIndex} initialTileIndex={vehicle.initialTileIndex} direction={rowData.direction} speed={rowData.speed} color={vehicle.color} /> ))} </Road> );}
The road component is the foundation and container for the car
and truck
lanes. It contains a gray plane.
Similar to the Grass
component, its size is determined by the constants tileSize
and tilesPerRow
. Unlike the Grass
component, though, it doesn’t have any height.
The Road
component serves as a container for the cars on the road. It has a children
prop. The CarLane
and TruckLane
components pass in the vehicles as props.
import { tilesPerRow, tileSize } from "../constants";
type Props = { rowIndex: number; children?: React.ReactNode;};
export function Road({ rowIndex, children }: Props) { return ( <group position-y={rowIndex * tileSize}> <mesh> <planeGeometry args={[tilesPerRow * tileSize, tileSize]} /> <meshLambertMaterial color={0x454a59} flatShading /> </mesh> {children} </group> );}
import { tilesPerRow, tileSize } from "../constants";
export function Road({ rowIndex, children }) { return ( <group position-y={rowIndex * tileSize}> <mesh> <planeGeometry args={[tilesPerRow * tileSize, tileSize]} /> <meshLambertMaterial color={0x454a59} flatShading /> </mesh> {children} </group> );}
The Car
component is a simplified 3D model of a car. It consists of a box for the body, a smaller box for the top, and two Wheel
components for the wheels.
We group all these elements together, position them based on the initialTileIndex
prop, and turn them based on the direction
prop. If the car goes to the left, we rotate it 180 degrees, which is equivalent to Math.PI
in radians.
There are two props that we don’t use yet: rowIndex
and speed
. We will use them later to implement the car movement and hit detection.
Grab the car to see it from different angles.
import * as THREE from "three";import { tileSize } from "../constants";import { Wheel } from "./Wheel";
type Props = { rowIndex: number; initialTileIndex: number; direction: boolean; speed: number; color: THREE.ColorRepresentation;};
export function Car({ rowIndex, initialTileIndex, direction, speed, color,}: Props) { return ( <group position-x={initialTileIndex * tileSize} rotation-z={direction ? 0 : Math.PI} > <mesh position={[0, 0, 12]}> <boxGeometry args={[60, 30, 15]} /> <meshLambertMaterial color={color} flatShading /> </mesh> <mesh position={[-6, 0, 25.5]}> <boxGeometry args={[33, 24, 12]} /> <meshLambertMaterial color={0xffffff} flatShading /> </mesh> <Wheel x={-18} /> <Wheel x={18} /> </group> );}
import { tileSize } from "../constants";import { Wheel } from "./Wheel";
export function Car({ rowIndex, initialTileIndex, direction, speed, color,}) { return ( <group position-x={initialTileIndex * tileSize} rotation-z={direction ? 0 : Math.PI} > <mesh position={[0, 0, 12]}> <boxGeometry args={[60, 30, 15]} /> <meshLambertMaterial color={color} flatShading /> </mesh> <mesh position={[-6, 0, 25.5]}> <boxGeometry args={[33, 24, 12]} /> <meshLambertMaterial color={0xffffff} flatShading /> </mesh> <Wheel x={-18} /> <Wheel x={18} /> </group> );}
To continue with our boxy theme, the Wheel
component is 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.
export function Wheel({ x }: { x: number }) { return ( <mesh position={[x, 0, 6]}> <boxGeometry args={[12, 33, 12]} /> <meshLambertMaterial color={0x333333} flatShading /> </mesh> );}
export function Wheel({ x }) { return ( <mesh position={[x, 0, 6]}> <boxGeometry args={[12, 33, 12]} /> <meshLambertMaterial color={0x333333} flatShading /> </mesh> );}
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.
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.
import type { Row } from "./types";
export const rows: Row[] = [ { type: "truck", direction: true, speed: 0, vehicles: [{ initialTileIndex: -4, color: 0x00ff00 }], },];
export const rows = [ { type: "truck", direction: true, speed: 0, vehicles: [{ initialTileIndex: -4, color: 0x00ff00 }], },];
Let’s extend the Row
Component with support for truck lanes. If a row type is truck
, we map it to a TruckLane
component. With this, the Row
component reached its final form.
import type { Row } from "../types";import { Forest } from "./Forest";import { CarLane } from "./CarLane";import { TruckLane } from "./TruckLane";
type Props = { rowIndex: number; rowData: Row;};
export function Row({ rowIndex, rowData }: Props) { switch (rowData.type) { case "forest": { return <Forest rowIndex={rowIndex} rowData={rowData} />; } case "car": { return <CarLane rowIndex={rowIndex} rowData={rowData} />; } case "truck": { return <TruckLane rowIndex={rowIndex} rowData={rowData} />; } }}
import { Forest } from "./Forest";import { CarLane } from "./CarLane";import { TruckLane } from "./TruckLane";
export function Row({ rowIndex, rowData }) { switch (rowData.type) { case "forest": { return <Forest rowIndex={rowIndex} rowData={rowData} />; } case "car": { return <CarLane rowIndex={rowIndex} rowData={rowData} />; } case "truck": { return <TruckLane rowIndex={rowIndex} rowData={rowData} />; } }}
Then, let’s define the TruckLane
component. This follows the same structure as the CarLane
component. We use the same Road
component as a container and foundation, and this time, we map the vehicles array to Truck
components.
import type { Row } from "../types";import { Road } from "./Road";import { Truck } from "./Truck";
type Props = { rowIndex: number; rowData: Extract<Row, { type: "truck" }>;};
export function TruckLane({ rowIndex, rowData }: Props) { return ( <Road rowIndex={rowIndex}> {rowData.vehicles.map((vehicle, index) => ( <Truck key={index} rowIndex={rowIndex} color={vehicle.color} initialTileIndex={vehicle.initialTileIndex} direction={rowData.direction} speed={rowData.speed} /> ))} </Road> );}
import { Road } from "./Road";import { Truck } from "./Truck";
export function TruckLane({ rowIndex, rowData }) { return ( <Road rowIndex={rowIndex}> {rowData.vehicles.map((vehicle, index) => ( <Truck key={index} rowIndex={rowIndex} color={vehicle.color} initialTileIndex={vehicle.initialTileIndex} direction={rowData.direction} speed={rowData.speed} /> ))} </Road> );}
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
prop.
We position the truck on the lane using the initialTilePosition
prop and turn it in the right direction using the direction
prop.
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.
import * as THREE from "three";import { tileSize } from "../constants";import { Wheel } from "./Wheel";
type Props = { rowIndex: number; initialTileIndex: number; direction: boolean; speed: number; color: THREE.ColorRepresentation;};
export function Truck({ rowIndex, initialTileIndex, direction, speed, color,}: Props) { return ( <group position-x={initialTileIndex * tileSize} rotation-z={direction ? 0 : Math.PI} > <mesh position={[-15, 0, 25]}> <boxGeometry args={[70, 35, 35]} /> <meshLambertMaterial color={0xb4c6fc} flatShading /> </mesh> <mesh position={[35, 0, 20]}> <boxGeometry args={[30, 30, 30]} /> <meshLambertMaterial color={color} flatShading /> </mesh> <Wheel x={-35} /> <Wheel x={5} /> <Wheel x={37} /> </group> );}
import { tileSize } from "../constants";import { Wheel } from "./Wheel";
export function Truck({ rowIndex, initialTileIndex, direction, speed, color,}) { return ( <group position-x={initialTileIndex * tileSize} rotation-z={direction ? 0 : Math.PI} > <mesh position={[-15, 0, 25]}> <boxGeometry args={[70, 35, 35]} /> <meshLambertMaterial color={0xb4c6fc} flatShading /> </mesh> <mesh position={[35, 0, 20]}> <boxGeometry args={[30, 30, 30]} /> <meshLambertMaterial color={color} flatShading /> </mesh> <Wheel x={-35} /> <Wheel x={5} /> <Wheel x={37} /> </group> );}
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.
As a finishing touch, let’s add a few empty grass rows in the Map
component before the starting position.
import { rows } from "../metadata";import { Grass } from "./Grass";import { Row } from "./Row";
export function Map() { return ( <> <Grass rowIndex={0} /> <Grass rowIndex={-1} /> <Grass rowIndex={-2} /> <Grass rowIndex={-3} /> <Grass rowIndex={-4} />
{rows.map((rowData, index) => ( <Row key={index} rowIndex={index + 1} rowData={rowData} /> ))} </> );}
import { rows } from "../metadata";import { Grass } from "./Grass";import { Row } from "./Row";
export function Map() { return ( <> <Grass rowIndex={0} /> <Grass rowIndex={-1} /> <Grass rowIndex={-2} /> <Grass rowIndex={-3} /> <Grass rowIndex={-4} />
{rows.map((rowData, index) => ( <Row key={index} rowIndex={index + 1} rowData={rowData} /> ))} </> );}
The metadata for the next few chapters and the demo above is as follows.
import type { Row } from "./types";
export const rows: 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 rows = [ { 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 }, ], },];