Hit Detection and Result Screen

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

Result Screen

On game over, we will display a popup with the final score and a reset button. Let’s implement this popup.

Adding the Result Screen

Once the player gets hit by a vehicle, the game is over. We show a result screen with the final score and a button to restart the game. Let’s extend our HTML file with the following code.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<canvas class="game"></canvas>
<div id="controls">
<div>
<button id="forward">▲</button>
<button id="left">◀</button>
<button id="backward">▼</button>
<button id="right">▶</button>
</div>
</div>
<div id="score">0</div>
<div id="result-container">
<div id="result">
<h1>Game Over</h1>
<p>Your score: <span id="final-score"></span></p>
<button id="retry">Retry</button>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<canvas class="game"></canvas>
<div id="controls">
<div>
<button id="forward">▲</button>
<button id="left">◀</button>
<button id="backward">▼</button>
<button id="right">▶</button>
</div>
</div>
<div id="score">0</div>
<div id="result-container">
<div id="result">
<h1>Game Over</h1>
<p>Your score: <span id="final-score"></span></p>
<button id="retry">Retry</button>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

Styling the Result Screen

This popup 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/style.css
44 collapsed lines
@import url("https://fonts.googleapis.com/css?family=Press+Start+2P");
body {
margin: 0;
display: flex;
font-family: "Press Start 2P", cursive;
}
#controls {
position: absolute;
bottom: 0;
min-width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
#controls div {
display: grid;
grid-template-columns: 50px 50px 50px;
grid-template-rows: auto auto;
grid-column-gap: 10px;
grid-row-gap: 10px;
margin-bottom: 20px;
}
#controls button {
width: 100%;
background-color: white;
border: 1px solid lightgray;
}
#controls button:first-of-type {
grid-column: 1/-1;
}
#score {
position: absolute;
top: 20px;
left: 20px;
font-size: 2em;
color: white;
}
#result-container {
position: absolute;
min-width: 100%;
min-height: 100%;
top: 0;
display: flex;
align-items: center;
justify-content: center;
visibility: hidden;
#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;
}
}

Hit Detection

We add a new function that checks if a vehicles hit the player. If so, we show the result screen.

Adding Hit Detection

Let’s define another function to handle hit detection. We check if the player intersects with any of the vehicles. If they do, we show the result screen.

We check which row the player is currently in. The index is off by one because the row metadata does not include the starting row. If the player is in the starting row, we get undefined. We ignore that case.

If the player is in a car or truck lane, we loop over the vehicles in that row and check if they intersect with the player. We will call this function in the animate function 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 set the result popup to visible and set the final score in the appropriate field.

src/hitTest.ts
import * as THREE from "three";
import { metadata as rows } from "./components/Map";
import { player, position } from "./components/Player";
const resultDOM = document.getElementById("result-container");
const finalScoreDOM = document.getElementById("final-score");
export function hitTest() {
const row = rows[position.currentRow - 1];
if (!row) return;
if (row.type === "car" || row.type === "truck") {
const playerBoundingBox = new THREE.Box3();
playerBoundingBox.setFromObject(player);
row.vehicles.forEach(({ ref }) => {
if (!ref) throw Error("Vehicle reference is missing");
const vehicleBoundingBox = new THREE.Box3();
vehicleBoundingBox.setFromObject(ref);
if (playerBoundingBox.intersectsBox(vehicleBoundingBox)) {
if (!resultDOM || !finalScoreDOM) return;
resultDOM.style.visibility = "visible";
finalScoreDOM.innerText = position.currentRow.toString();
}
});
}
}
src/hitTest.js
import * as THREE from "three";
import { metadata as rows } from "./components/Map";
import { player, position } from "./components/Player";
const resultDOM = document.getElementById("result-container");
const finalScoreDOM = document.getElementById("final-score");
export function hitTest() {
const row = rows[position.currentRow - 1];
if (!row) return;
if (row.type === "car" || row.type === "truck") {
const playerBoundingBox = new THREE.Box3();
playerBoundingBox.setFromObject(player);
row.vehicles.forEach(({ ref }) => {
if (!ref) throw Error("Vehicle reference is missing");
const vehicleBoundingBox = new THREE.Box3();
vehicleBoundingBox.setFromObject(ref);
if (playerBoundingBox.intersectsBox(vehicleBoundingBox)) {
if (!resultDOM || !finalScoreDOM) return;
resultDOM.style.visibility = "visible";
finalScoreDOM.innerText = position.currentRow.toString();
}
});
}
}

We call this function in the animate function that will run it on every frame.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import { animatePlayer } from "./animatePlayer";
import { hitTest } from "./hitTest";
import "./style.css";
import "./collectUserInput";
22 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
dirLight.target = player;
player.add(dirLight);
const camera = Camera();
player.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
hitTest();
renderer.render(scene, camera);
}
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import { animatePlayer } from "./animatePlayer";
import { hitTest } from "./hitTest";
import "./style.css";
import "./collectUserInput";
22 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
dirLight.target = player;
player.add(dirLight);
const camera = Camera();
player.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
hitTest();
renderer.render(scene, camera);
}

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

When the player is hit by a vehicle, a result screen with a “Retry” button appears. Let’s add an event handler to this button in the main file.

On a click event, we call the initializeGame function that we used before to render the initial map. Now, this function will serve two purposes: rendering the initial map and resetting the game. Let’s extend it accordingly.

We already call to initialize the map. We are going to extend its logic to reset the map, too. However, we don’t have any logic to reset the player. Let’s import a new function from the player and call it here. We will define this function in the next step.

Finally, we reset the UI elements. We set the score indicator back to zero and hide the result screen.

Now, let’s look into how to reset the player and the map.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player, initializePlayer } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import { animatePlayer } from "./animatePlayer";
import { hitTest } from "./hitTest";
import "./style.css";
import "./collectUserInput";
13 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
dirLight.target = player;
player.add(dirLight);
const camera = Camera();
player.add(camera);
const scoreDOM = document.getElementById("score");
const resultDOM = document.getElementById("result-container");
initializeGame();
document
.querySelector("#retry")
?.addEventListener("click", initializeGame);
function initializeGame() {
initializePlayer();
initializeMap();
// Initialize UI
if (scoreDOM) scoreDOM.innerText = "0";
if (resultDOM) resultDOM.style.visibility = "hidden";
}
10 collapsed lines
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
hitTest();
renderer.render(scene, camera);
}
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player, initializePlayer } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import { animatePlayer } from "./animatePlayer";
import { hitTest } from "./hitTest";
import "./style.css";
import "./collectUserInput";
13 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
dirLight.target = player;
player.add(dirLight);
const camera = Camera();
player.add(camera);
const scoreDOM = document.getElementById("score");
const resultDOM = document.getElementById("result-container");
initializeGame();
document
.querySelector("#retry")
?.addEventListener("click", initializeGame);
function initializeGame() {
initializePlayer();
initializeMap();
// Initialize UI
if (scoreDOM) scoreDOM.innerText = "0";
if (resultDOM) resultDOM.style.visibility = "hidden";
}
10 collapsed lines
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
hitTest();
renderer.render(scene, camera);
}

Resetting the Player

To reset the player, let’s export an initializePlayer function from the player. This function resets the metadata and the player object’s position and rotation in the scene.

src/components/Player.ts
4 collapsed lines
import * as THREE from "three";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import { metadata as rows, addRows } from "./Map";
import type { MoveDirection } from "../types";
export const player = Player();
32 collapsed lines
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.castShadow = true;
body.receiveShadow = true;
body.position.z = 10;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
const playerContainer = new THREE.Group();
playerContainer.add(player);
return playerContainer;
}
export const position: {
currentRow: number;
currentTile: number;
} = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue: MoveDirection[] = [];
export function initializePlayer() {
// Initialize the Three.js player object
player.position.x = 0;
player.position.y = 0;
player.children[0].position.z = 0;
// Initialize metadata
position.currentRow = 0;
position.currentTile = 0;
// Clear the moves queue
movesQueue.length = 0;
}
28 collapsed lines
export function queueMove(direction: MoveDirection) {
const isValidMove = endsUpInValidPosition(
{
rowIndex: position.currentRow,
tileIndex: position.currentTile,
},
[...movesQueue, direction]
);
if (!isValidMove) return;
movesQueue.push(direction);
}
export function stepCompleted() {
const direction = movesQueue.shift();
if (direction === "forward") position.currentRow += 1;
if (direction === "backward") position.currentRow -= 1;
if (direction === "left") position.currentTile -= 1;
if (direction === "right") position.currentTile += 1;
// Add new rows if the player is running out of them
if (position.currentRow > rows.length - 10) addRows();
const scoreDOM = document.getElementById("score");
if (scoreDOM) scoreDOM.innerText = position.currentRow.toString();
}
src/components/Player.js
3 collapsed lines
import * as THREE from "three";
import { endsUpInValidPosition } from "../utilities/endsUpInValidPosition";
import { metadata as rows, addRows } from "./Map";
export const player = Player();
32 collapsed lines
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.castShadow = true;
body.receiveShadow = true;
body.position.z = 10;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
const playerContainer = new THREE.Group();
playerContainer.add(player);
return playerContainer;
}
export const position = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue = [];
export function initializePlayer() {
// Initialize the Three.js player object
player.position.x = 0;
player.position.y = 0;
player.children[0].position.z = 0;
// Initialize metadata
position.currentRow = 0;
position.currentTile = 0;
// Clear the moves queue
movesQueue.length = 0;
}
28 collapsed lines
export function queueMove(direction) {
const isValidMove = endsUpInValidPosition(
{
rowIndex: position.currentRow,
tileIndex: position.currentTile,
},
[...movesQueue, direction]
);
if (!isValidMove) return;
movesQueue.push(direction);
}
export function stepCompleted() {
const direction = movesQueue.shift();
if (direction === "forward") position.currentRow += 1;
if (direction === "backward") position.currentRow -= 1;
if (direction === "left") position.currentTile -= 1;
if (direction === "right") position.currentTile += 1;
// Add new rows if the player is running out of them
if (position.currentRow > rows.length - 10) addRows();
const scoreDOM = document.getElementById("score");
if (scoreDOM) scoreDOM.innerText = position.currentRow.toString();
}

Resetting the Map

Finally, to reset the map store, we extend the initializeMap method to reset the metadata and remove all existing rows from the map before it generates a new set of rows.

src/components/Map.ts
8 collapsed lines
import type { Row } from "../types";
import * as THREE from "three";
import { generateRows } from "../utilities/generateRows";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
export const metadata: Row[] = [];
export const map = new THREE.Group();
export function initializeMap() {
// Remove all rows
metadata.length = 0;
map.remove(...map.children);
// Add new rows
for (let rowIndex = 0; rowIndex > -5; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
53 collapsed lines
export function addRows() {
const newMetadata = generateRows(20);
const startIndex = metadata.length;
metadata.push(...newMetadata);
newMetadata.forEach((rowData, index) => {
const rowIndex = startIndex + 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
);
vehicle.ref = car;
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
);
vehicle.ref = truck;
row.add(truck);
});
map.add(row);
}
});
}
src/components/Map.js
7 collapsed lines
import * as THREE from "three";
import { generateRows } from "../utilities/generateRows";
import { Grass } from "./Grass";
import { Road } from "./Road";
import { Tree } from "./Tree";
import { Car } from "./Car";
import { Truck } from "./Truck";
export const metadata = [];
export const map = new THREE.Group();
export function initializeMap() {
// Remove all rows
metadata.length = 0;
map.remove(...map.children);
// Add new rows
for (let rowIndex = 0; rowIndex > -5; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
53 collapsed lines
export function addRows() {
const newMetadata = generateRows(20);
const startIndex = metadata.length;
metadata.push(...newMetadata);
newMetadata.forEach((rowData, index) => {
const rowIndex = startIndex + 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
);
vehicle.ref = car;
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
);
vehicle.ref = truck;
row.add(truck);
});
map.add(row);
}
});
}

Congratulations!

You made it to the end of this tutorial. You have learned how to create a simple game using 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 Three.js