Hit Detection and Result Screen

Crossy Road with React Three Fiber ● Chapter 10

In this chapter, we implement hit detection and add a result screen. We check if the player gets hit by a vehicle, and if so, we show the final score and add logic to reset the game.

Hit Detection

We add a new hook that checks from the vehicles’ perspective if they hit the player. If so, they change the game status to over.

Introducing the Game Status

We add a second property, status, to the game store to indicate whether the game is running or over. By default, the game is running.

We also add an endGame method to set the status to over. We will call this method when the player gets hit by a vehicle.

src/stores/game.ts
import { create } from "zustand";
interface StoreState {
status: "running" | "over";
score: number;
updateScore: (rowIndex: number) => void;
endGame: () => void;
}
const useStore = create<StoreState>((set) => ({
status: "running",
score: 0,
updateScore: (rowIndex: number) => {
set((state) => ({ score: Math.max(rowIndex, state.score) }));
},
endGame: () => {
set({ status: "over" });
},
}));
export default useStore;
src/stores/game.js
import { create } from "zustand";
const useStore = create((set) => ({
status: "running",
score: 0,
updateScore: (rowIndex) => {
set((state) => ({ score: Math.max(rowIndex, state.score) }));
},
endGame: () => {
set({ status: "over" });
},
}));
export default useStore;

Adding a Reference to the Player in the Store

So far, the player and the vehicles have handled their own movement independently. They have no notion of each other. To handle hit detection, either the player needs to know about the vehicles or the vehicles need to know about the player.

We chose the former approach because this way, we only need to store one reference to the player in the store, and all the vehicles can check against this reference.

Let’s extend the player store with a ref property to store the player object’s reference. We also expose a setRef method that sets this reference.

src/stores/player.ts
import * as THREE from "three";
import type { MoveDirection } from "../types";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import useMapStore from "./map";
import useGameStore from "./game";
export const state: {
currentRow: number;
currentTile: number;
movesQueue: MoveDirection[];
ref: THREE.Object3D | null;
} = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
ref: null,
};
26 collapsed lines
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 a batch of new rows if the player is running out of them; rows are infinite
if (state.currentRow === useMapStore.getState().rows.length - 10) {
useMapStore.getState().addRows();
}
useGameStore.getState().updateScore(state.currentRow);
}
export function setRef(ref: THREE.Object3D) {
state.ref = ref;
}
src/stores/player.js
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import useMapStore from "./map";
import useGameStore from "./game";
export const state = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
ref: null,
};
26 collapsed lines
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 a batch of new rows if the player is running out of them; rows are infinite
if (state.currentRow === useMapStore.getState().rows.length - 10) {
useMapStore.getState().addRows();
}
useGameStore.getState().updateScore(state.currentRow);
}
export function setRef(ref) {
state.ref = ref;
}

Then, we call the setRef method in the Player component to set the reference to the player object. We already have the player reference, so we can pass its value to the setRef method in the useEffect hook once it is set.

src/components/Player.tsx
import * as THREE from "three";
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { DirectionalLight } from "./DirectionalLight";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
import { setRef } from "../stores/player";
export function Player() {
const player = useRef<THREE.Group>(null);
const lightRef = useRef<THREE.DirectionalLight>(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
if (!lightRef.current) return;
// Attach the camera to the player
player.current.add(camera);
lightRef.current.target = player.current;
// Set the player reference in the store
setRef(player.current);
});
return (
15 collapsed lines
<Bounds fit={fit} clip observe margin={10}>
<group ref={player}>
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
<DirectionalLight ref={lightRef} />
</group>
</Bounds>
);
}
src/components/Player.jsx
import { Bounds } from "@react-three/drei";
import { useRef, useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { DirectionalLight } from "./DirectionalLight";
import usePlayerAnimation from "../hooks/usePlayerAnimation";
import { setRef } from "../stores/player";
export function Player() {
const player = useRef(null);
const lightRef = useRef(null);
const camera = useThree((state) => state.camera);
usePlayerAnimation(player);
useEffect(() => {
if (!player.current) return;
if (!lightRef.current) return;
// Attach the camera to the player
player.current.add(camera);
lightRef.current.target = player.current;
// Set the player reference in the store
setRef(player.current);
});
return (
15 collapsed lines
<Bounds fit={fit} clip observe margin={10}>
<group ref={player}>
<group>
<mesh position={[0, 0, 10]} castShadow receiveShadow>
<boxGeometry args={[15, 15, 20]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<mesh position={[0, 0, 21]} castShadow receiveShadow>
<boxGeometry args={[2, 4, 2]} />
<meshLambertMaterial color={0xf0619a} flatShading />
</mesh>
</group>
<DirectionalLight ref={lightRef} />
</group>
</Bounds>
);
}

Adding Hit Detection

Let’s define another hook to handle hit detection. We check if the player intersects with any of the vehicles. If they do, we set the game status to over.

This hook is from the perspective of a vehicle. It receives the vehicle reference and the rowIndex. We check if the vehicle intersects with the player if the player is in the same row, the row before, or the row after the vehicle. We use the useFrame hook to run the hit detection logic on every frame.

We create bounding boxes for the player and the vehicle to check for an intersection. This might be an overkill, as the shape of our objects is known, but it is a nice generic way to handle hit detection. If the bounding boxes intersect, we call the endGame function from the game store.

src/hooks/useHitDetection.ts
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { state as player } from "../stores/player";
import useGameStore from "../stores/game";
export default function useHitDetection(
vehicle: React.RefObject<THREE.Group | null>,
rowIndex: number
) {
const endGame = useGameStore((state) => state.endGame);
useFrame(() => {
if (!vehicle.current) return;
if (!player.ref) return;
if (
rowIndex === player.currentRow ||
rowIndex === player.currentRow + 1 ||
rowIndex === player.currentRow - 1
) {
const vehicleBoundingBox = new THREE.Box3();
vehicleBoundingBox.setFromObject(vehicle.current);
const playerBoundingBox = new THREE.Box3();
playerBoundingBox.setFromObject(player.ref);
if (playerBoundingBox.intersectsBox(vehicleBoundingBox)) {
endGame();
}
}
});
}
src/hooks/useHitDetection.js
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { state as player } from "../stores/player";
import useGameStore from "../stores/game";
export default function useHitDetection(vehicle, rowIndex) {
const endGame = useGameStore((state) => state.endGame);
useFrame(() => {
if (!vehicle.current) return;
if (!player.ref) return;
if (
rowIndex === player.currentRow ||
rowIndex === player.currentRow + 1 ||
rowIndex === player.currentRow - 1
) {
const vehicleBoundingBox = new THREE.Box3();
vehicleBoundingBox.setFromObject(vehicle.current);
const playerBoundingBox = new THREE.Box3();
playerBoundingBox.setFromObject(player.ref);
if (playerBoundingBox.intersectsBox(vehicleBoundingBox)) {
endGame();
}
}
});
}

Then, we call this hook in the vehicle components. First, we add it to the Car component. We pass the car reference and the rowIndex to the useHitDetection hook.

src/components/Car.tsx
import * as THREE from "three";
import { useRef } from "react";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
import useVehicleAnimation from "../hooks/useVehicleAnimation";
import useHitDetection from "../hooks/useHitDetection";
7 collapsed lines
type Props = {
rowIndex: number;
initialTileIndex: number;
direction: boolean;
speed: number;
color: THREE.ColorRepresentation;
};
export function Car({
rowIndex,
initialTileIndex,
direction,
speed,
color,
}: Props) {
const car = useRef<THREE.Group>(null);
useVehicleAnimation(car, direction, speed);
useHitDetection(car, rowIndex);
return (
16 collapsed lines
<group
position-x={initialTileIndex * tileSize}
rotation-z={direction ? 0 : Math.PI}
ref={car}
>
<mesh position={[0, 0, 12]} castShadow receiveShadow>
<boxGeometry args={[60, 30, 15]} />
<meshLambertMaterial color={color} flatShading />
</mesh>
<mesh position={[-6, 0, 25.5]} castShadow receiveShadow>
<boxGeometry args={[33, 24, 12]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<Wheel x={-18} />
<Wheel x={18} />
</group>
);
}
src/components/Car.jsx
import { useRef } from "react";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
import useVehicleAnimation from "../hooks/useVehicleAnimation";
import useHitDetection from "../hooks/useHitDetection";
export function Car({
rowIndex,
initialTileIndex,
direction,
speed,
color,
}) {
const car = useRef(null);
useVehicleAnimation(car, direction, speed);
useHitDetection(car, rowIndex);
return (
16 collapsed lines
<group
position-x={initialTileIndex * tileSize}
rotation-z={direction ? 0 : Math.PI}
ref={car}
>
<mesh position={[0, 0, 12]} castShadow receiveShadow>
<boxGeometry args={[60, 30, 15]} />
<meshLambertMaterial color={color} flatShading />
</mesh>
<mesh position={[-6, 0, 25.5]} castShadow receiveShadow>
<boxGeometry args={[33, 24, 12]} />
<meshLambertMaterial color={0xffffff} flatShading />
</mesh>
<Wheel x={-18} />
<Wheel x={18} />
</group>
);
}

Then, we do the same in the Truck component. If either a car or a truck hits the player, the game changes its status to over.

You can add further logic to, for instance, disable further movement of the player. We don’t cover this here, but you can add it as an exercise. Instead, let’s display a result screen with the final score.

src/components/Truck.tsx
import * as THREE from "three";
import { useRef } from "react";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
import useVehicleAnimation from "../hooks/useVehicleAnimation";
import useHitDetection from "../hooks/useHitDetection";
7 collapsed lines
type Props = {
rowIndex: number;
initialTileIndex: number;
direction: boolean;
speed: number;
color: THREE.ColorRepresentation;
};
export function Truck({
rowIndex,
initialTileIndex,
direction,
speed,
color,
}: Props) {
const truck = useRef<THREE.Group>(null);
useVehicleAnimation(truck, direction, speed);
useHitDetection(truck, rowIndex);
return (
17 collapsed lines
<group
position-x={initialTileIndex * tileSize}
rotation-z={direction ? 0 : Math.PI}
ref={truck}
>
<mesh position={[-15, 0, 25]} castShadow receiveShadow>
<boxGeometry args={[70, 35, 35]} />
<meshLambertMaterial color={0xb4c6fc} flatShading />
</mesh>
<mesh position={[35, 0, 20]} castShadow receiveShadow>
<boxGeometry args={[30, 30, 30]} />
<meshLambertMaterial color={color} flatShading />
</mesh>
<Wheel x={-35} />
<Wheel x={5} />
<Wheel x={37} />
</group>
);
}
src/components/Truck.jsx
import { useRef } from "react";
import { tileSize } from "../constants";
import { Wheel } from "./Wheel";
import useVehicleAnimation from "../hooks/useVehicleAnimation";
import useHitDetection from "../hooks/useHitDetection";
export function Truck({
rowIndex,
initialTileIndex,
direction,
speed,
color,
}) {
const truck = useRef(null);
useVehicleAnimation(truck, direction, speed);
useHitDetection(truck, rowIndex);
return (
17 collapsed lines
<group
position-x={initialTileIndex * tileSize}
rotation-z={direction ? 0 : Math.PI}
ref={truck}
>
<mesh position={[-15, 0, 25]} castShadow receiveShadow>
<boxGeometry args={[70, 35, 35]} />
<meshLambertMaterial color={0xb4c6fc} flatShading />
</mesh>
<mesh position={[35, 0, 20]} castShadow receiveShadow>
<boxGeometry args={[30, 30, 30]} />
<meshLambertMaterial color={color} flatShading />
</mesh>
<Wheel x={-35} />
<Wheel x={5} />
<Wheel x={37} />
</group>
);
}

Result Screen

On game over, we display a popup with the final score and a reset button.

The Result Screen

Once the player gets hit by a vehicle, the game status is set to over. We show a result screen with the final score and a button to restart the game.

Let’s add a new component called Result. This regular React component reads the game state from the store and displays the result if the game is over. It also has a button to restart the game. In the next section, we add the necessary logic to reset the game.

src/components/Result.tsx
import useStore from "../stores/game";
import "./Result.css";
export function Result() {
const status = useStore((state) => state.status);
const score = useStore((state) => state.score);
const reset = useStore((state) => state.reset);
if (status === "running") return null;
return (
<div id="result-container">
<div id="result">
<h1>Game Over</h1>
<p>Your score: {score}</p>
<button onClick={reset}>Retry</button>
</div>
</div>
);
}
src/components/Result.jsx
import useStore from "../stores/game";
import "./Result.css";
export function Result() {
const status = useStore((state) => state.status);
const score = useStore((state) => state.score);
const reset = useStore((state) => state.reset);
if (status === "running") return null;
return (
<div id="result-container">
<div id="result">
<h1>Game Over</h1>
<p>Your score: {score}</p>
<button onClick={reset}>Retry</button>
</div>
</div>
);
}

The Result component also has some style. We style the result as a centered container with a white background and a red button to restart the game. By default, the result screen is hidden.

src/components/Result.css
#result-container {
position: absolute;
min-width: 100%;
min-height: 100%;
top: 0;
display: flex;
align-items: center;
justify-content: center;
#result {
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
padding: 20px;
}
button {
background-color: red;
padding: 20px 50px 20px 50px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
}

Finally, let’s add this new component to the root of the Game component. With that, only one piece is missing to complete the game: the logic to reset the game.

src/Game.tsx
import { Scene } from "./components/Scene";
import { Player } from "./components/Player";
import { Map } from "./components/Map";
import { Score } from "./components/Score";
import { Controls } from "./components/Controls";
import { Result } from "./components/Result";
import "./index.css";
export default function Game() {
return (
<div className="game">
<Scene>
<Player />
<Map />
</Scene>
<Score />
<Controls />
<Result />
</div>
);
}
src/Game.jsx
import { Scene } from "./components/Scene";
import { Player } from "./components/Player";
import { Map } from "./components/Map";
import { Score } from "./components/Score";
import { Controls } from "./components/Controls";
import { Result } from "./components/Result";
import "./index.css";
export default function Game() {
return (
<div className="game">
<Scene>
<Player />
<Map />
</Scene>
<Score />
<Controls />
<Result />
</div>
);
}

Resetting the Game

Once the player is hit by a vehicle, the result screen appears. Here, we have a button to restart the game. Let’s implement the logic behind it.

Resetting the Game

A result screen is shown when the player gets hit by a vehicle. There, if the player clicks the “Retry” button, we call the reset method of the game store. Let’s implement this method.

The reset method resets the game to running status, sets the score to 0, and calls the other two stores to reset themselves.

Let’s look into these two other stores next.

src/stores/game.ts
import { create } from "zustand";
import useMapStore from "./map";
import { reset as resetPlayerStore } from "./player";
interface StoreState {
status: "running" | "over";
score: number;
updateScore: (rowIndex: number) => void;
endGame: () => void;
reset: () => void;
}
const useStore = create<StoreState>((set) => ({
status: "running",
score: 0,
updateScore: (rowIndex: number) => {
set((state) => ({ score: Math.max(rowIndex, state.score) }));
},
endGame: () => {
set({ status: "over" });
},
reset: () => {
useMapStore.getState().reset();
resetPlayerStore();
set({ status: "running", score: 0 });
},
}));
export default useStore;
src/stores/game.js
import { create } from "zustand";
import useMapStore from "./map";
import { reset as resetPlayerStore } from "./player";
const useStore = create((set) => ({
status: "running",
score: 0,
updateScore: (rowIndex) => {
set((state) => ({ score: Math.max(rowIndex, state.score) }));
},
endGame: () => {
set({ status: "over" });
},
reset: () => {
useMapStore.getState().reset();
resetPlayerStore();
set({ status: "running", score: 0 });
},
}));
export default useStore;

Resetting the Map

To reset the map store, we expose a reset method that generates new rows and sets them as the current map. We use the same generateRows function that we used to initialize the rows in the first place.

Calling this method will reactively re-render the map.

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

Resetting the Player

Finally, we need to reset the player store. The reset method returns the player to the starting position and clears the moves queue.

This function resets the metadata and the player object’s position and rotation in the scene.

src/stores/player.ts
48 collapsed lines
import * as THREE from "three";
import type { MoveDirection } from "../types";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import useMapStore from "./map";
import useGameStore from "./game";
export const state: {
currentRow: number;
currentTile: number;
movesQueue: MoveDirection[];
ref: THREE.Group | null;
} = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
ref: null,
};
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 a batch of new rows if the player is running out of them; rows are infinite
if (state.currentRow === useMapStore.getState().rows.length - 10) {
useMapStore.getState().addRows();
}
useGameStore.getState().updateScore(state.currentRow);
}
export function setRef(ref: THREE.Group) {
state.ref = ref;
}
export function reset() {
state.currentRow = 0;
state.currentTile = 0;
state.movesQueue = [];
if (!state.ref) return;
state.ref.position.x = 0;
state.ref.position.y = 0;
state.ref.children[0].rotation.z = 0;
}
src/stores/player.js
41 collapsed lines
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import useMapStore from "./map";
import useGameStore from "./game";
export const state = {
currentRow: 0,
currentTile: 0,
movesQueue: [],
ref: null,
};
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 a batch of new rows if the player is running out of them; rows are infinite
if (state.currentRow === useMapStore.getState().rows.length - 10) {
useMapStore.getState().addRows();
}
useGameStore.getState().updateScore(state.currentRow);
}
export function setRef(ref) {
state.ref = ref;
}
export function reset() {
state.currentRow = 0;
state.currentTile = 0;
state.movesQueue = [];
if (!state.ref) return;
state.ref.position.x = 0;
state.ref.position.y = 0;
state.ref.children[0].rotation.z = 0;
}

Congratulations!

You made it to the end of this tutorial. You have learned how to create a simple game using React Three Fiber and Three.js. The game is far from perfect. You can now extend this game by adding more features, obstacles, or a new level design.

To grow this site it would help a lot if you could share it with your friends or colleagues. So far this is a pilot project, but I plan to extend this site with more tutorials in the future. If you have any feedback or suggestions, please let me know and in the meanwhile follow me on YouTube and LinkedIn.

Previous:
Score Indicator
Tutorial:
Crossy Road with React Three Fiber