Game Development 30 min read

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.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Build a Crossy‑Road Style 3D Game with Three.js – A Step‑by‑Step Guide

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.

JavaScriptGame DevelopmentTutorialThree.jsWebGL3Dcollision detection
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.