Build a Crossy‑Road Style 3D Game with Three.js – A Step‑by‑Step Guide
This tutorial walks you through creating a lightweight, child‑friendly 3D Crossy Road‑style game using Three.js, covering scene setup, metadata‑driven terrain generation, asset loading, player movement queues, dynamic collision detection, UI communication, and deployment tips, all illustrated with code snippets and screenshots.
Prerequisites
Welcome to a fun, child‑oriented Three.js tutorial designed for the upcoming Children’s Day. You only need basic Three.js knowledge to follow along.
What you will learn
Basic Three.js usage
Techniques for acquiring custom 3D resources
Simple Three.js game development ideas
Page Preview
During the holiday season, the author reflects on moving from backend CRUD work to creating this game.
Three.js Game Three Essentials
The three core elements are Scene, Game UI, and Metadata, analogous to a playground’s field, signboards, and rules.
Element
Purpose
Real‑world Analogy
Scene
3D environment
Playground ground
Game UI
User interface layer
Signboards and ticket booths
Metadata
Game data and logic
Playground rules and visitor data
Why it matters
Scene – handles models, lighting, physics.
UI – controls score display and menus.
Metadata – manages game state, scoring, character attributes.
Resource Acquisition
3D Model Generation
Using an AI image generator to create a simple 2.5D chicken image, then feeding it to an AI 3D generation service (e.g., hyper3D) to obtain a model.
Background Music
Music is generated with Suno AI; for more options, visit Opengameart’s music section.
❝ Input style description: "8‑bit retro game music with cheerful melody" ❞
Metadata & Basic Scene Construction
Analyze the original “Crossy Road” scene to extract required metadata and build a static scene using that data.
<code>const metadata = [
// first row
{ type: 'forest', trees: [ { tileIndex: -7, type: 'tree01' }, { tileIndex: -3, type: 'tree02' } ] },
// second row
{ type: 'road', direction: true, speed: 1, vehicles: [ { initialTileIndex: 12, type: 'car04' }, { initialTileIndex: 2, type: 'car08' }, { initialTileIndex: -2, type: 'car01' } ] }
];</code>Terrain Generation
Generate grass rows by cloning a grass tile mesh and positioning it along the X axis.
<code>export default class Grass {
constructor(scene, object3d, rowIndex = 0) {
this.scene = scene;
this.object3d = object3d;
this.rowIndex = rowIndex;
this.tiles = [];
this.createGrassRow();
}
createGrassRow() {
const tileResource = this.object3d;
if (!tileResource) { console.warn('Missing grass resource'); return; }
for (let i = 0; i < 16; i++) {
const tileIndex = MIN_TILE_INDEX + i;
const tileMesh = tileResource.scene.clone();
tileMesh.position.set(tileIndex, 0, this.rowIndex);
this.scene.add(tileMesh);
this.tiles.push(tileMesh);
}
}
}
</code>Dynamic Elements
Trees and cars are generated based on metadata arrays.
<code>export default class Tree {
constructor(scene, resources, trees, rowIndex = 0) {
this.scene = scene;
this.resources = resources;
this.trees = trees;
this.rowIndex = rowIndex;
this.treeMeshes = [];
this.addTrees();
}
addTrees() {
this.trees.forEach(({ tileIndex, type }) => {
const treeResource = this.resources.items[type];
const treeMesh = treeResource.scene.clone();
treeMesh.position.set(tileIndex, 0.2, this.rowIndex);
this.scene.add(treeMesh);
this.treeMeshes.push(treeMesh);
});
}
}
</code> <code>export default class Car {
constructor(scene, resources, vehicles, rowIndex = 0, direction = false, speed = 1) {
this.scene = scene;
this.resources = resources;
this.vehicles = vehicles;
this.rowIndex = rowIndex;
this.direction = direction;
this.speed = speed;
this.carMeshes = [];
this.addCars();
}
addCars() {
this.vehicles.forEach(({ initialTileIndex, type }) => {
const carResource = this.resources.items[type];
if (!carResource) { console.warn(`Missing resource: ${type}`); return; }
const carMesh = carResource.scene.clone();
carMesh.scale.set(0.5, 0.5, 0.5);
carMesh.traverse(child => { if (child.isMesh) child.castShadow = true; });
carMesh.position.set(initialTileIndex, 0.35, this.rowIndex);
if (this.direction) carMesh.rotation.y = 0; else carMesh.rotation.y = Math.PI;
this.scene.add(carMesh);
this.carMeshes.push(carMesh);
});
}
}
</code>Character Movement & Scene Generation
The chicken character is loaded, scaled, and added to the scene. Movement uses a queue ( movesQueue ) so each key press enqueues a full step, ensuring consistent motion regardless of key‑hold duration.
<code>listenKeyboard() {
window.addEventListener('keydown', event => {
if (this.experience.isPaused) return;
let move = null;
switch (event.code) {
case 'ArrowUp': case 'KeyW': move = 'forward'; break;
case 'ArrowDown': case 'KeyS': move = 'backward'; break;
case 'ArrowLeft': case 'KeyA': move = 'left'; break;
case 'ArrowRight': case 'KeyD': move = 'right'; break;
}
if (move && !this.pressedKeys.has(event.code)) {
this.movesQueue.push(move);
this.pressedKeys.add(event.code);
}
});
window.addEventListener('keyup', event => this.pressedKeys.delete(event.code));
}
</code>During each animation frame, the next target tile is calculated, rotation is interpolated, and the move is executed. After completion, the command is removed from the queue.
<code>update() {
if (!this.instance || !this.movesQueue.length) return;
if (!this.isMoving) {
const dir = this.movesQueue[0];
this.targetTile = { ...this.currentTile };
if (dir === 'forward') this.targetTile.z -= 1;
else if (dir === 'backward') this.targetTile.z += 1;
else if (dir === 'left') this.targetTile.x -= 1;
else if (dir === 'right') this.targetTile.x += 1;
this.startRot = this.instance.rotation.y;
this.endRot = getTargetRotation(dir);
this.isMoving = true;
this.moveClock.start();
this.startPos = { x: this.currentTile.x * this.stepLength, z: this.currentTile.z * this.stepLength };
this.endPos = { x: this.targetTile.x * this.stepLength, z: this.targetTile.z * this.stepLength };
}
const stepTime = this.isSpeedUp ? SPEEDUP_STEP_TIME : NORMAL_STEP_TIME;
const progress = Math.min(1, this.moveClock.getElapsedTime() / stepTime);
this.setPosition(progress);
this.setRotation(progress);
if (progress >= 1) {
this.stepCompleted();
this.moveClock.stop();
this.isMoving = false;
this.movesQueue.shift();
}
}
</code>Dynamic Terrain Extension
When the player approaches the edge of the current map, checkAndExtendMap adds new rows generated by generateMetaRows and renders them.
<code>extendMap(N = 10) {
const startRowIndex = this.metadata.length;
const newRows = generateMetaRows(N);
this.metadata.push(...newRows);
newRows.forEach(rowData => {
this.rowIndex++;
if (rowData.type === 'forest') { this.addGrassRow(this.rowIndex); this.addTreeRow(rowData.trees, this.rowIndex); }
if (rowData.type === 'road') { this.addRoadRow(this.rowIndex); this.addCarRow(rowData.vehicles, this.rowIndex, rowData.direction, rowData.speed); }
});
}
checkAndExtendMap(userZ) {
const remainRows = this.metadata.length - Math.abs(userZ);
if (remainRows < GENERATION_COUNT) this.extendMap(GENERATION_COUNT);
}
</code>Collision Detection
Tree collisions are checked by validating the target tile against metadata; invalid moves are discarded.
<code>export function endsUpInValidPosition(targetTile, metaData) {
if (targetTile.x < MIN_TILE_INDEX || targetTile.x > MAX_TILE_INDEX) return false;
if (targetTile.z <= -5) return false;
const rowIndex = targetTile.z;
const row = metaData[rowIndex - 1];
if (row && row.type === 'forest') {
if (row.trees.some(tree => tree.tileIndex === targetTile.x)) return false;
}
return true;
}
</code>Car collisions use axis‑aligned bounding boxes (AABB) limited to the current road row for performance.
<code>update() {
if (this.map) {
this.map.update();
if (this.user && !this.isGameOver) {
this.map.checkAndExtendMap(this.user.currentTile.z);
const playerMesh = this.user.instance;
if (playerMesh) {
const playerRow = this.user.currentTile.z;
const carMeshes = this.map.getCarMeshesByRow(playerRow);
if (carMeshes.length) {
const playerBox = new THREE.Box3().setFromObject(playerMesh);
for (const carMesh of carMeshes) {
const carBox = new THREE.Box3().setFromObject(carMesh);
if (playerBox.intersectsBox(carBox)) {
this.onGameOver();
}
}
}
}
this.user.update();
}
}
}
</code>UI Communication
The core Experience class extends an EventEmitter , enabling a publish‑subscribe pattern between the 3D scene and 2D UI. UI components listen to events such as pause , resume , or custom game events, while the scene triggers them as needed.
<code>import EventEmitter from './utils/event-emitter.js';
export default class Experience extends EventEmitter {
constructor(canvas) {
if (instance) return instance;
super();
instance = this;
window.Experience = this;
this.canvas = canvas;
// instantiate subsystems …
this.sizes.on('resize', () => this.resize());
this.time.on('tick', () => this.update());
this.on('pause', () => { this.isPaused = true; });
this.on('resume', () => { this.isPaused = false; });
}
resize() { this.camera.resize(); this.renderer.resize(); }
update() { if (this.isPaused) return; this.camera.update(); this.world.update(); this.renderer.update(); this.stats.update(); this.iMouse.update(); }
}
</code>By emitting events like trigger('scoreUpdate', score) the scene can inform the UI to refresh score displays, timers, or show game‑over screens.
Conclusion & Community
The author invites readers to try the game, explore hidden Easter eggs, and join the community for further Three.js learning and open‑source collaboration.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.