<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>
<a id="youtube" target="_top" href="https://youtu.be/vNr3_hQ3Bws">
<span>Learn Three.js while building this game on YouTube</span>
</a>
<div id="youtube-card">
Learn Three.js while building this game on YouTube
</div>
@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: 20px;
min-width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
#controls div {
display: grid;
grid-template-columns: 50px 50px 50px;
gap: 10px;
}
#controls button {
width: 100%;
height: 40px;
background-color: white;
border: 1px solid lightgray;
box-shadow: 3px 5px 0px 0px rgba(0, 0, 0, 0.75);
cursor: pointer;
outline: none;
}
#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;
}
}
#youtube,
#youtube-card {
display: none;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
color: black;
}
@media (min-height: 425px) {
/** Youtube logo by https://codepen.io/alvaromontoro */
#youtube {
z-index: 50;
width: 100px;
display: block;
height: 70px;
position: fixed;
bottom: 20px;
right: 20px;
background: red;
border-radius: 50% / 11%;
transform: scale(0.8);
transition: transform 0.5s;
}
#youtube:hover,
#youtube:focus {
transform: scale(0.9);
color: black;
}
#youtube::before {
content: "";
display: block;
position: absolute;
top: 7.5%;
left: -6%;
width: 112%;
height: 85%;
background: red;
border-radius: 9% / 50%;
}
#youtube::after {
content: "";
display: block;
position: absolute;
top: 20px;
left: 40px;
width: 45px;
height: 30px;
border: 15px solid transparent;
box-sizing: border-box;
border-left: 30px solid white;
}
#youtube span {
font-size: 0;
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
#youtube:hover + #youtube-card {
z-index: 49;
display: block;
position: fixed;
bottom: 12px;
right: 10px;
padding: 25px 130px 25px 25px;
width: 300px;
background-color: white;
}
}
/*
Learn how to code this watch step by step on YouTube:
https://youtu.be/vNr3_hQ3Bws
or on:
https://javascriptgametutorials.com/
*/
import * as THREE from "https://esm.sh/three";
const minTileIndex = -8;
const maxTileIndex = 8;
const tilesPerRow = maxTileIndex - minTileIndex + 1;
const tileSize = 42;
function Camera() {
const size = 300;
const viewRatio = window.innerWidth / window.innerHeight;
const width = viewRatio < 1 ? size : size * viewRatio;
const height = viewRatio < 1 ? size / viewRatio : size;
const camera = new THREE.OrthographicCamera(
width / -2, // left
width / 2, // right
height / 2, // top
height / -2, // bottom
100, // near
900 // far
);
camera.up.set(0, 0, 1);
camera.position.set(300, -300, 300);
camera.lookAt(0, 0, 0);
return camera;
}
function Texture(width, height, rects) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.fillStyle = "rgba(0,0,0,0.6)";
rects.forEach((rect) => {
context.fillRect(rect.x, rect.y, rect.w, rect.h);
});
return new THREE.CanvasTexture(canvas);
}
const carFrontTexture = new Texture(40, 80, [{ x: 0, y: 10, w: 30, h: 60 }]);
const carBackTexture = new Texture(40, 80, [{ x: 10, y: 10, w: 30, h: 60 }]);
const carRightSideTexture = new Texture(110, 40, [
{ x: 10, y: 0, w: 50, h: 30 },
{ x: 70, y: 0, w: 30, h: 30 },
]);
const carLeftSideTexture = new Texture(110, 40, [
{ x: 10, y: 10, w: 50, h: 30 },
{ x: 70, y: 10, w: 30, h: 30 },
]);
export const truckFrontTexture = Texture(30, 30, [
{ x: 5, y: 0, w: 10, h: 30 },
]);
export const truckRightSideTexture = Texture(25, 30, [
{ x: 15, y: 5, w: 10, h: 10 },
]);
export const truckLeftSideTexture = Texture(25, 30, [
{ x: 15, y: 15, w: 10, h: 10 },
]);
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;
main.castShadow = true;
main.receiveShadow = true;
car.add(main);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(33, 24, 12), [
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carBackTexture,
}),
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carFrontTexture,
}),
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carRightSideTexture,
}),
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carLeftSideTexture,
}),
new THREE.MeshPhongMaterial({ color: 0xcccccc, flatShading: true }), // top
new THREE.MeshPhongMaterial({ color: 0xcccccc, flatShading: true }), // bottom
]);
cabin.position.x = -6;
cabin.position.z = 25.5;
cabin.castShadow = true;
cabin.receiveShadow = true;
car.add(cabin);
const frontWheel = Wheel(18);
car.add(frontWheel);
const backWheel = Wheel(-18);
car.add(backWheel);
return car;
}
function DirectionalLight() {
const dirLight = new THREE.DirectionalLight();
dirLight.position.set(-100, -100, 200);
dirLight.up.set(0, 0, 1);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.up.set(0, 0, 1);
dirLight.shadow.camera.left = -400;
dirLight.shadow.camera.right = 400;
dirLight.shadow.camera.top = 400;
dirLight.shadow.camera.bottom = -400;
dirLight.shadow.camera.near = 50;
dirLight.shadow.camera.far = 400;
return dirLight;
}
function Grass(rowIndex) {
const grass = new THREE.Group();
grass.position.y = rowIndex * tileSize;
const createSection = (color) =>
new THREE.Mesh(
new THREE.BoxGeometry(tilesPerRow * tileSize, tileSize, 3),
new THREE.MeshLambertMaterial({ color })
);
const middle = createSection(0xbaf455);
middle.receiveShadow = true;
grass.add(middle);
const left = createSection(0x99c846);
left.position.x = -tilesPerRow * tileSize;
grass.add(left);
const right = createSection(0x99c846);
right.position.x = tilesPerRow * tileSize;
grass.add(right);
return grass;
}
const metadata = [];
const map = new THREE.Group();
function initializeMap() {
// Remove all rows
metadata.length = 0;
map.remove(map.children);
// Add new rows
for (let rowIndex = 0; rowIndex > -10; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
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);
}
});
}
const player = Player();
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.position.z = 10;
body.castShadow = true;
body.receiveShadow = true;
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;
}
const position = {
currentRow: 0,
currentTile: 0,
};
const movesQueue = [];
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;
}
function queueMove(direction) {
const isValidMove = endsUpInValidPosition(
{
rowIndex: position.currentRow,
tileIndex: position.currentTile,
},
[movesQueue, direction]
);
if (!isValidMove) return;
movesQueue.push(direction);
}
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 > metadata.length - 10) addRows();
const scoreDOM = document.getElementById("score");
if (scoreDOM) scoreDOM.innerText = position.currentRow.toString();
}
function Renderer() {
const canvas = document.querySelector("canvas.game");
if (!canvas) throw new Error("Canvas not found");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
return renderer;
}
function Road(rowIndex) {
const road = new THREE.Group();
road.position.y = rowIndex * tileSize;
const createSection = (color) =>
new THREE.Mesh(
new THREE.PlaneGeometry(tilesPerRow * tileSize, tileSize),
new THREE.MeshLambertMaterial({ color })
);
const middle = createSection(0x454a59);
middle.receiveShadow = true;
road.add(middle);
const left = createSection(0x393d49);
left.position.x = -tilesPerRow * tileSize;
road.add(left);
const right = createSection(0x393d49);
right.position.x = tilesPerRow * tileSize;
road.add(right);
return road;
}
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;
crown.castShadow = true;
crown.receiveShadow = true;
tree.add(crown);
return tree;
}
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;
cargo.castShadow = true;
cargo.receiveShadow = true;
truck.add(cargo);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(30, 30, 30), [
new THREE.MeshLambertMaterial({
color,
flatShading: true,
map: truckFrontTexture,
}), // front
new THREE.MeshLambertMaterial({
color,
flatShading: true,
}), // back
new THREE.MeshLambertMaterial({
color,
flatShading: true,
map: truckLeftSideTexture,
}),
new THREE.MeshLambertMaterial({
color,
flatShading: true,
map: truckRightSideTexture,
}),
new THREE.MeshPhongMaterial({ color, flatShading: true }), // top
new THREE.MeshPhongMaterial({ color, flatShading: true }), // bottom
]);
cabin.position.x = 35;
cabin.position.z = 20;
cabin.castShadow = true;
cabin.receiveShadow = true;
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;
}
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;
}
function calculateFinalPosition(currentPosition, moves) {
return moves.reduce((position, direction) => {
if (direction === "forward")
return {
rowIndex: position.rowIndex + 1,
tileIndex: position.tileIndex,
};
if (direction === "backward")
return {
rowIndex: position.rowIndex - 1,
tileIndex: position.tileIndex,
};
if (direction === "left")
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex - 1,
};
if (direction === "right")
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex + 1,
};
return position;
}, currentPosition);
}
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 = metadata[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;
}
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 };
}
const moveClock = new THREE.Clock(false);
function animatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(1, moveClock.getElapsedTime() / stepTime);
setPosition(progress);
setRotation(progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
}
function setPosition(progress) {
const startX = position.currentTile * tileSize;
const startY = position.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (movesQueue[0] === "left") endX -= tileSize;
if (movesQueue[0] === "right") endX += tileSize;
if (movesQueue[0] === "forward") endY += tileSize;
if (movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.children[0].position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(progress) {
let endRotation = 0;
if (movesQueue[0] == "forward") endRotation = 0;
if (movesQueue[0] == "left") endRotation = Math.PI / 2;
if (movesQueue[0] == "right") endRotation = -Math.PI / 2;
if (movesQueue[0] == "backward") endRotation = Math.PI;
player.children[0].rotation.z = THREE.MathUtils.lerp(
player.children[0].rotation.z,
endRotation,
progress
);
}
const clock = new THREE.Clock();
function animateVehicles() {
const delta = clock.getDelta();
// Animate cars and trucks
metadata.forEach((rowData) => {
if (rowData.type === "car" || rowData.type === "truck") {
const beginningOfRow = (minTileIndex - 2) * tileSize;
const endOfRow = (maxTileIndex + 2) * tileSize;
rowData.vehicles.forEach(({ ref }) => {
if (!ref) throw Error("Vehicle reference is missing");
if (rowData.direction) {
ref.position.x =
ref.position.x > endOfRow
? beginningOfRow
: ref.position.x + rowData.speed * delta;
} else {
ref.position.x =
ref.position.x < beginningOfRow
? endOfRow
: ref.position.x - rowData.speed * delta;
}
});
}
});
}
document
.getElementById("forward")
?.addEventListener("click", () => queueMove("forward"));
document
.getElementById("backward")
?.addEventListener("click", () => queueMove("backward"));
document
.getElementById("left")
?.addEventListener("click", () => queueMove("left"));
document
.getElementById("right")
?.addEventListener("click", () => queueMove("right"));
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowUp") {
event.preventDefault(); // Avoid scrolling the page
queueMove("forward");
} else if (event.key === "ArrowDown") {
event.preventDefault(); // Avoid scrolling the page
queueMove("backward");
} else if (event.key === "ArrowLeft") {
event.preventDefault(); // Avoid scrolling the page
queueMove("left");
} else if (event.key === "ArrowRight") {
event.preventDefault(); // Avoid scrolling the page
queueMove("right");
}
});
function hitTest() {
const row = metadata[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();
}
});
}
}
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");
const finalScoreDOM = document.getElementById("final-score");
initializeGame();
document.querySelector("#retry")?.addEventListener("click", initializeGame);
function initializeGame() {
initializePlayer();
initializeMap();
// Initialize UI
if (scoreDOM) scoreDOM.innerText = "0";
if (resultDOM) resultDOM.style.visibility = "hidden";
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
hitTest();
renderer.render(scene, camera);
}
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.