Generating the Map

Crossy Road with React Three Fiber ● Chapter 8

So far, we have rendered the map based on static metadata. Now, it’s time to generate the map randomly and dynamically expand the level as the player moves forward. In this chapter, we add utility functions that generate metadata and introduce a zustand store to manage the rows.

Generating Metadata

Generating Metadata for Map

Let’s create a utility file to generate metadata. In this, we export the generateRows function, which returns the metadata for n amount of rows.

The generateRows function calls the generateRow function, which generates the metadata for one single row. It randomly selects a row type: car, truck, or forest.

It uses a helper function randomElement to pick a random element from an array.

Now, let’s look into the functions that generate the metadata for each row type.

src/utilities/generateRows.ts
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
import { type Row, type RowType } from "../types";
export function generateRows(amount: number): Row[] {
const rows: Row[] = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow(): Row {
const type: RowType = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
src/utilities/generateRows.js
import { minTileIndex, maxTileIndex } from "../constants";
export function generateRows(amount) {
const rows = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow() {
const type = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}

Generating Metadata for a Forest

Let’s continue with the same file and add the generateForesMetadata function, which returns metadata for a forest row.

To generate metadata for a forest, we need to generate an object with a type property set to forest and an array of trees. Each tree has a tileIndex and a height.

We generate four trees in a forest. We pick a random tileIndex for each based on the available range. We use THREE.MathUtils.randInt to pick a random integer number between the minimum and maximum tile index. To avoid overlapping trees, we keep track of the occupied tiles in the occupiedTiles set. If the generated position is already occupied, we keep generating a new tile index until we find one that is free.

Let’s say we pick tile -3 for the first tree. We mark the tile in the set as occupied, and the following trees won’t be placed there.

We also pick a random height for each tree from the array [20, 45, 60].

src/utilities/generateRows.ts
23 collapsed lines
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
import { type Row, type RowType } from "../types";
export function generateRows(amount: number): Row[] {
const rows: Row[] = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow(): Row {
const type: RowType = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata(): Row {
const occupiedTiles = new Set<number>();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: "forest", trees };
}
src/utilities/generateRows.js
22 collapsed lines
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
export function generateRows(amount) {
const rows = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow() {
const type = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata() {
const occupiedTiles = new Set();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: "forest", trees };
}

Generating Metadata for a Car Lane

Generating car lanes is very similar. Car lanes also have a direction and speed property. The direction is a boolean value determining whether the cars move left or right. The speed property tells us how much distance each car moves in one second. We pick a random value for both properties with our utility function.

Then, we need to generate an array of vehicles with the initialTileIndex and color properties. We generate three cars in a lane. The logic here is similar to before, except that cars are longer. We mark three tiles as occupied for each vehicle.

src/utilities/generateRows.ts
40 collapsed lines
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
import { type Row, type RowType } from "../types";
export function generateRows(amount: number): Row[] {
const rows: Row[] = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow(): Row {
const type: RowType = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata(): Row {
const occupiedTiles = new Set<number>();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: "forest", trees };
}
function generateCarLaneMetadata(): Row {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set<number>();
const vehicles = Array.from({ length: 3 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
const color: THREE.ColorRepresentation = randomElement([
0xa52523, 0xbdb638, 0x78b14b,
]);
return { initialTileIndex, color };
});
return { type: "car", direction, speed, vehicles };
}
src/utilities/generateRows.js
39 collapsed lines
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
export function generateRows(amount) {
const rows = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow() {
const type = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata() {
const occupiedTiles = new Set();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: "forest", trees };
}
function generateCarLaneMetadata() {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set();
const vehicles = Array.from({ length: 3 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
const color = randomElement([0xa52523, 0xbdb638, 0x78b14b]);
return { initialTileIndex, color };
});
return { type: "car", direction, speed, vehicles };
}

Generating Metadata for a Truck Lane

Generating metadata for a truck lane is essentially the same as generating metadata for a car lane. The only difference is that a truck lane has only two vehicles, and because trucks are longer than cars, they occupy more tiles.

src/utilities/generateRows.ts
68 collapsed lines
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
import { type Row, type RowType } from "../types";
export function generateRows(amount: number): Row[] {
const rows: Row[] = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow(): Row {
const type: RowType = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata(): Row {
const occupiedTiles = new Set<number>();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: "forest", trees };
}
function generateCarLaneMetadata(): Row {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set<number>();
const vehicles = Array.from({ length: 3 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
const color: THREE.ColorRepresentation = randomElement([
0xa52523, 0xbdb638, 0x78b14b,
]);
return { initialTileIndex, color };
});
return { type: "car", direction, speed, vehicles };
}
function generateTruckLaneMetadata(): Row {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set<number>();
const vehicles = Array.from({ length: 2 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 2);
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
occupiedTiles.add(initialTileIndex + 2);
const color: THREE.ColorRepresentation = randomElement([
0xa52523, 0xbdb638, 0x78b14b,
]);
return { initialTileIndex, color };
});
return { type: "truck", direction, speed, vehicles };
}
src/utilities/generateRows.js
65 collapsed lines
import * as THREE from "three";
import { minTileIndex, maxTileIndex } from "../constants";
export function generateRows(amount) {
const rows = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow() {
const type = randomElement(["car", "truck", "forest"]);
if (type === "car") return generateCarLaneMetadata();
if (type === "truck") return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata() {
const occupiedTiles = new Set();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: "forest", trees };
}
function generateCarLaneMetadata() {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set();
const vehicles = Array.from({ length: 3 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
const color = randomElement([0xa52523, 0xbdb638, 0x78b14b]);
return { initialTileIndex, color };
});
return { type: "car", direction, speed, vehicles };
}
function generateTruckLaneMetadata() {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set();
const vehicles = Array.from({ length: 2 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 2);
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
occupiedTiles.add(initialTileIndex + 2);
const color = randomElement([0xa52523, 0xbdb638, 0x78b14b]);
return { initialTileIndex, color };
});
return { type: "truck", direction, speed, vehicles };
}

Using a Map Store

After defining the utility functions to generate metadata, let’s use them in the new map store. The map store is a zustand store that manages the rows in the game. This time, we need a reactive store because the number of rows is dynamic. As the player moves forward, we extend the map.

The Map Store

Let’s create a new zustand store to manage the map metadata. For now, it has two things. The rows property holds the metadata of all the rows in the game, and the addRows function extends the map. In both cases, we use the generateRows function we just defined.

As we introduce this new store, we can also delete the static metadata file we used before.

src/stores/map.ts
import { create } from "zustand";
import { generateRows } from "../utilities/generateRows";
import type { Row } from "../types";
interface StoreState {
rows: Row[];
addRows: () => void;
}
const useStore = create<StoreState>((set) => ({
rows: generateRows(20),
addRows: () => {
const newRows = generateRows(20);
set((state) => ({ rows: [...state.rows, ...newRows] }));
},
}));
export default useStore;
src/stores/map.js
import { create } from "zustand";
import { generateRows } from "../utilities/generateRows";
const useStore = create((set) => ({
rows: generateRows(20),
addRows: () => {
const newRows = generateRows(20);
set((state) => ({ rows: [...state.rows, ...newRows] }));
},
}));
export default useStore;

Using the Map Store

Then, in the Map component, we remove the static metadata import and map the rows property from the new zustand store.

Because zustand is reactive, the Map component will re-render whenever we add new rows to the store.

src/components/Map.tsx
import { Row } from "./Row";
import { Grass } from "./Grass";
import { rows } from "../metadata";
import useStore from "../stores/map";
export function Map() {
const rows = useStore((state) => state.rows);
return (
<>
<Grass rowIndex={0} />
<Grass rowIndex={-1} />
<Grass rowIndex={-2} />
<Grass rowIndex={-3} />
<Grass rowIndex={-4} />
<Grass rowIndex={-5} />
<Grass rowIndex={-6} />
<Grass rowIndex={-7} />
<Grass rowIndex={-8} />
<Grass rowIndex={-9} />
<Grass rowIndex={-10} />
{rows.map((rowData, index) => (
<Row key={index} rowIndex={index + 1} rowData={rowData} />
))}
</>
);
}
src/components/Map.jsx
import { Row } from "./Row";
import { Grass } from "./Grass";
import { rows } from "../metadata";
import useStore from "../stores/map";
export function Map() {
const rows = useStore((state) => state.rows);
return (
<>
<Grass rowIndex={0} />
<Grass rowIndex={-1} />
<Grass rowIndex={-2} />
<Grass rowIndex={-3} />
<Grass rowIndex={-4} />
<Grass rowIndex={-5} />
<Grass rowIndex={-6} />
<Grass rowIndex={-7} />
<Grass rowIndex={-8} />
<Grass rowIndex={-9} />
<Grass rowIndex={-10} />
{rows.map((rowData, index) => (
<Row key={index} rowIndex={index + 1} rowData={rowData} />
))}
</>
);
}

Updating the Move Validation Logic

Now that our metadata file is gone, we must also replace it in the utility function that checks if a move is valid. We used the metadata to check whether the player would hit a tree. We can replace the metadata import with the useMapStore hook.

Then, we can replace the rows variable with useMapStore.getState().rows. This is another way of accessing a zustand store’s state. The main difference between this sort of access and the useStore hook is that this way is not reactive. This is useful when we don’t need reactivity.

src/utilities/endsUpInValidPosition.ts
import type { MoveDirection } from "../types";
import { calculateFinalPosition } from "./calculateFinalPosition";
import { minTileIndex, maxTileIndex } from "../constants";
import { rows } from "../metadata";
import useMapStore from "../stores/map";
export function endsUpInValidPosition(
currentPosition: { rowIndex: number; tileIndex: number },
moves: MoveDirection[]
) {
// Calculate where the player would end up after the move
const finalPosition = calculateFinalPosition(
currentPosition,
moves
);
// Detect if we hit the edge of the board
if (
finalPosition.rowIndex === -1 ||
finalPosition.tileIndex === minTileIndex - 1 ||
finalPosition.tileIndex === maxTileIndex + 1
) {
// Invalid move, ignore move command
return false;
}
// Detect if we hit a tree
const finalRow =
useMapStore.getState().rows[finalPosition.rowIndex - 1];
if (
finalRow &&
finalRow.type === "forest" &&
finalRow.trees.some(
(tree) => tree.tileIndex === finalPosition.tileIndex
)
) {
// Invalid move, ignore move command
return false;
}
return true;
}
src/utilities/endsUpInValidPosition.js
import { calculateFinalPosition } from "./calculateFinalPosition";
import { minTileIndex, maxTileIndex } from "../constants";
import { rows } from "../metadata";
import useMapStore from "../stores/map";
export function endsUpInValidPosition(currentPosition, moves) {
// Calculate where the player would end up after the move
const finalPosition = calculateFinalPosition(
currentPosition,
moves
);
// Detect if we hit the edge of the board
if (
finalPosition.rowIndex === -1 ||
finalPosition.tileIndex === minTileIndex - 1 ||
finalPosition.tileIndex === maxTileIndex + 1
) {
// Invalid move, ignore move command
return false;
}
// Detect if we hit a tree
const finalRow =
useMapStore.getState().rows[finalPosition.rowIndex - 1];
if (
finalRow &&
finalRow.type === "forest" &&
finalRow.trees.some(
(tree) => tree.tileIndex === finalPosition.tileIndex
)
) {
// Invalid move, ignore move command
return false;
}
return true;
}

Expanding the Map as the Player Moves Forward

Now that we have a dynamic store with a function to extend the map, we can use it to add new rows as the player moves forward.

Extending the Player Store

In the player store, we have the stepCompleted function, which is called whenever the player completes a step. This is a good place to evaluate whether we need to add new rows to the game. If the player is running out of the map, we can call the addRows function from the map store.

We check if the player is 10 rows away from the end of the current map. If so, we call the addRows function from the map store. This will trigger the reactivity in the Map component and render the new rows.

As a result, we have an infinite amount of rows. As the player moves forward, more and more rows are added to the map.

src/stores/player.ts
import type { MoveDirection } from "../types";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import useMapStore from "./map";
20 collapsed lines
export const state: {
currentRow: number;
currentTile: number;
movesQueue: MoveDirection[];
} = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
};
export function queueMove(direction: MoveDirection) {
const isValidMove = endsUpInValidPosition(
{ rowIndex: state.currentRow, tileIndex: state.currentTile },
[...state.movesQueue, direction]
);
if (!isValidMove) return;
state.movesQueue.push(direction);
}
export function stepCompleted() {
const direction = state.movesQueue.shift();
if (direction === "forward") state.currentRow += 1;
if (direction === "backward") state.currentRow -= 1;
if (direction === "left") state.currentTile -= 1;
if (direction === "right") state.currentTile += 1;
// Add new rows if the player is running out of them
if (state.currentRow === useMapStore.getState().rows.length - 10) {
useMapStore.getState().addRows();
}
}
src/stores/player.js
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import useMapStore from "./map";
16 collapsed lines
export const state = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
};
export function queueMove(direction) {
const isValidMove = endsUpInValidPosition(
{ rowIndex: state.currentRow, tileIndex: state.currentTile },
[...state.movesQueue, direction]
);
if (!isValidMove) return;
state.movesQueue.push(direction);
}
export function stepCompleted() {
const direction = state.movesQueue.shift();
if (direction === "forward") state.currentRow += 1;
if (direction === "backward") state.currentRow -= 1;
if (direction === "left") state.currentTile -= 1;
if (direction === "right") state.currentTile += 1;
// Add new rows if the player is running out of them
if (state.currentRow === useMapStore.getState().rows.length - 10) {
useMapStore.getState().addRows();
}
}
Previous:
Following the Player
Next:
Score Indicator