ANIMATE & CLICK
MINECRAFT MODELS

Load a blocky Minecraft zombie into Three.js, stand it on a field of grass blocks, play its idle and walking animations, and click it with a Raycaster to make it hop, flash red and start walking a circle.

✓ FREE Level 1 · Beginner Raycaster clicksAnimationMixerInstancedMesh

By Shane Brumback · Updated November 2, 2026 · about 12 minute read

LIVE DEMO

The zombie on the right of this page is the live scene. Click it (a click, not a drag) to make it hop and walk. Launch the full-screen demo to click the zombie, drag to orbit and scroll to zoom. Launch asks for a free Google sign-in first. Press Esc or Exit to return.

Free Google sign-in · Click the zombie · Orbit: drag · Zoom: scroll · Esc to exit

Introduction

Loading a model is only half of a game. The other half is interaction: the player clicks something and the world reacts. In this free tutorial you build a tiny Minecraft-style scene: a field of grass blocks, a blocky zombie with real skeletal animations, and click detection that makes the zombie hop, flash red and switch between standing and walking.

You will use three tools you will reach for in almost every Three.js game: GLTFLoader to load the model, AnimationMixer to play its clips, and Raycaster to find out what the mouse or finger touched.

How It Works (Key Ideas)

  • Raycasting — convert the click into a point from -1 to 1 on screen (normalized device coordinates), shoot a ray from the camera through it, and ask which meshes it crosses.
  • Click vs drag — the camera also listens to drags. Measuring how far the pointer moved between down and up keeps orbit drags from counting as clicks.
  • Recursive hits — a GLB is a tree of many meshes. intersectObject(zombie, true) checks all its children.
  • InstancedMesh — 400 grass blocks drawn in one draw call, each with its own position and color.
  • Cloned materials — we clone each material before tinting it red so other models that share it are not affected.

Common Problems

  • Clicks never hit: the pointer math must use the canvas size. This demo fills the window, so innerWidth and innerHeight are right; for a smaller canvas use getBoundingClientRect().
  • Every drag counts as a click: compare pointerdown and pointerup positions, as in step 5.
  • Zombie sinks into the floor: set its y from the Box3 minimum after scaling, as in step 3.

Step-by-Step Code Breakdown

Every excerpt below is copied exactly from the full code further down, which is the same code running the live scene on this page.

1. Scene, camera and renderer

In plain words: we make the 3D world with a bright daytime sky color, a camera a few steps back from where the zombie will stand, and a renderer with shadows turned on.

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x7fb2e5);
scene.fog = new THREE.Fog(0x7fb2e5, 14, 34);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 2.4, 7);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

2. Lights and a blocky grass floor

In plain words: a sky light plus a sun light (which casts the shadow). The floor is a field of grass blocks: one shared box shape drawn 400 times with an InstancedMesh, which costs one draw call instead of 400. Each block gets a slightly different green so it looks like Minecraft grass.

scene.add(new THREE.HemisphereLight(0xffffff, 0x4a5a2a, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(4, 8, 5);
sun.castShadow = true;
scene.add(sun);

const blocks = new THREE.InstancedMesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshLambertMaterial(),
  400
);
const m = new THREE.Matrix4(), c = new THREE.Color();
let n = 0;
for (let x = -10; x < 10; x++) for (let z = -10; z < 10; z++) {
  m.setPosition(x + 0.5, -0.5, z + 0.5);
  blocks.setMatrixAt(n, m);
  blocks.setColorAt(n++, c.setHSL(0.27, 0.55, 0.32 + Math.random() * 0.08));
}
blocks.receiveShadow = true;
scene.add(blocks);

3. Load the Minecraft zombie and fit it

In plain words: GLTFLoader downloads the zombie .glb. A Box3 measures it so we can scale it to 2 units tall and set its feet on top of the grass. We also collect its materials so we can flash it red later when it is clicked.

let zombie = null, mixer = null, idle = null, walk = null, baseY = 0;
const mats = [];
new GLTFLoader().load('/models/gltf/minecraft-zombie/zombie-001.glb', (gltf) => {
  zombie = gltf.scene;
  const box = new THREE.Box3().setFromObject(zombie, true);   // true = precise fit
  zombie.scale.setScalar(2 / box.getSize(new THREE.Vector3()).y);
  box.setFromObject(zombie, true);
  baseY = zombie.position.y = -box.min.y;             // stand on the grass
  zombie.traverse((o) => {
    if (o.isMesh) { o.castShadow = true; o.material = o.material.clone(); mats.push(o.material); }
  });
  scene.add(zombie);
  setupAnimations(gltf.animations);
});

4. Idle and walk animations

In plain words: the file has an idle clip and a walking clip. We find each one by name, wrap them in actions on an AnimationMixer, and start with idle. toggleWalk() cross-fades between the two over a third of a second, so the zombie eases into its stride.

function setupAnimations(clips) {
  mixer = new THREE.AnimationMixer(zombie);
  idle = mixer.clipAction(clips.find((c) => /idle/.test(c.name)));
  walk = mixer.clipAction(clips.find((c) => /walking/.test(c.name)));
  idle.play();
}
let walking = false;
function toggleWalk() {
  walking = !walking;
  const from = walking ? idle : walk, to = walking ? walk : idle;
  to.reset().play();
  from.crossFadeTo(to, 0.35, false);
}

5. Click the zombie with a Raycaster

In plain words: a Raycaster shoots an invisible line from the camera through the spot you clicked. If that line touches any part of the zombie, we count it as a hit: it hops, flashes red and switches between standing and walking. We only count a click if the pointer barely moved, so dragging to orbit the camera does not trigger it.

const ray = new THREE.Raycaster(), pointer = new THREE.Vector2();
let downX = 0, downY = 0, hop = 0;
renderer.domElement.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
renderer.domElement.addEventListener('pointerup', (e) => {
  if (!zombie || Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; // a drag, not a click
  pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
  ray.setFromCamera(pointer, camera);
  if (ray.intersectObject(zombie, true).length) {
    hop = 1;                      // start a hop + red flash
    toggleWalk();
    document.getElementById('ui').textContent = walking ? 'Walking! Click again to stop.' : 'Idle. Click the zombie to walk.';
  }
});

6. Animate: walk a circle, hop and flash

In plain words: every frame the mixer moves the skeleton. While walking, the zombie moves around a circle and turns to face the way it is going (the direction of travel on a circle is sideways to the radius, so its facing angle is the circle angle plus or minus a quarter turn). A click makes hop count down from 1 to 0, which drives both a little jump and the fading red glow.

const clock = new THREE.Clock();
let angle = 0;
function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  if (mixer) mixer.update(dt);
  if (zombie) {
    if (walking) {
      angle += dt * 0.45;
      zombie.position.x = Math.sin(angle) * 2.2;
      zombie.position.z = Math.cos(angle) * 2.2 - 2.2;
      zombie.rotation.y = angle + Math.PI / 2;      // face along the circle
    }
    hop = Math.max(0, hop - dt * 2);
    zombie.position.y = baseY + Math.sin(hop * Math.PI) * 0.6;
    for (const mat of mats) if (mat.emissive) mat.emissive.setRGB(hop * 0.9, 0, 0);
  }
  controls.update();
  renderer.render(scene, camera);
}
animate();

The Full Code

Paste this into a file named index.html. Serve it from a local web server with the model at /models/gltf/minecraft-zombie/zombie-001.glb. Launch Full Code opens this exact code as a standalone page in a new window.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Three.js Minecraft Zombie: Animate and Click</title>
  <style>
    body { margin: 0; overflow: hidden; background: #000; font-family: system-ui, sans-serif; }
    #ui { position: fixed; left: 16px; bottom: 16px; padding: 10px 16px; border-radius: 8px; background: rgba(10,15,28,0.8); color: #fff; font: 600 14px system-ui, sans-serif; pointer-events: none; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
    }
  }
  </script>
</head>
<body>
<div id="ui">Click the zombie to make it walk.</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// --- 1. Scene, camera and renderer ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x7fb2e5);
scene.fog = new THREE.Fog(0x7fb2e5, 14, 34);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 2.4, 7);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

// Orbit controls: drag (mouse or touch) to orbit. Zoom is off on the page so
// vertical swipes still scroll on phones; the full-screen demo turns zoom on.
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
controls.enableZoom = false;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.4;
renderer.domElement.style.touchAction = 'pan-y';

// --- 2. Lights and a blocky grass floor ---
scene.add(new THREE.HemisphereLight(0xffffff, 0x4a5a2a, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(4, 8, 5);
sun.castShadow = true;
scene.add(sun);

const blocks = new THREE.InstancedMesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshLambertMaterial(),
  400
);
const m = new THREE.Matrix4(), c = new THREE.Color();
let n = 0;
for (let x = -10; x < 10; x++) for (let z = -10; z < 10; z++) {
  m.setPosition(x + 0.5, -0.5, z + 0.5);
  blocks.setMatrixAt(n, m);
  blocks.setColorAt(n++, c.setHSL(0.27, 0.55, 0.32 + Math.random() * 0.08));
}
blocks.receiveShadow = true;
scene.add(blocks);

// --- 3. Load the Minecraft zombie and fit it ---
let zombie = null, mixer = null, idle = null, walk = null, baseY = 0;
const mats = [];
new GLTFLoader().load('/models/gltf/minecraft-zombie/zombie-001.glb', (gltf) => {
  zombie = gltf.scene;
  const box = new THREE.Box3().setFromObject(zombie, true);   // true = precise fit
  zombie.scale.setScalar(2 / box.getSize(new THREE.Vector3()).y);
  box.setFromObject(zombie, true);
  baseY = zombie.position.y = -box.min.y;             // stand on the grass
  zombie.traverse((o) => {
    if (o.isMesh) { o.castShadow = true; o.material = o.material.clone(); mats.push(o.material); }
  });
  scene.add(zombie);
  setupAnimations(gltf.animations);
});

// --- 4. Idle and walk animations ---
function setupAnimations(clips) {
  mixer = new THREE.AnimationMixer(zombie);
  idle = mixer.clipAction(clips.find((c) => /idle/.test(c.name)));
  walk = mixer.clipAction(clips.find((c) => /walking/.test(c.name)));
  idle.play();
}
let walking = false;
function toggleWalk() {
  walking = !walking;
  const from = walking ? idle : walk, to = walking ? walk : idle;
  to.reset().play();
  from.crossFadeTo(to, 0.35, false);
}

// --- 5. Click the zombie with a Raycaster ---
const ray = new THREE.Raycaster(), pointer = new THREE.Vector2();
let downX = 0, downY = 0, hop = 0;
renderer.domElement.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
renderer.domElement.addEventListener('pointerup', (e) => {
  if (!zombie || Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; // a drag, not a click
  pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
  ray.setFromCamera(pointer, camera);
  if (ray.intersectObject(zombie, true).length) {
    hop = 1;                      // start a hop + red flash
    toggleWalk();
    document.getElementById('ui').textContent = walking ? 'Walking! Click again to stop.' : 'Idle. Click the zombie to walk.';
  }
});

// --- 6. Animate: walk a circle, hop and flash ---
const clock = new THREE.Clock();
let angle = 0;
function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  if (mixer) mixer.update(dt);
  if (zombie) {
    if (walking) {
      angle += dt * 0.45;
      zombie.position.x = Math.sin(angle) * 2.2;
      zombie.position.z = Math.cos(angle) * 2.2 - 2.2;
      zombie.rotation.y = angle + Math.PI / 2;      // face along the circle
    }
    hop = Math.max(0, hop - dt * 2);
    zombie.position.y = baseY + Math.sin(hop * Math.PI) * 0.6;
    for (const mat of mats) if (mat.emissive) mat.emissive.setRGB(hop * 0.9, 0, 0);
  }
  controls.update();
  renderer.render(scene, camera);
}
animate();

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>

What To Try Next

  • Add more zombies with SkeletonUtils.clone() and give each its own mixer.
  • Make the zombie walk to the clicked block: raycast against blocks instead and move toward the hit point.
  • Play a sound on hit with THREE.Audio.
  • Learn more picking tricks in selecting Minecraft models using raycasters.

Frequently Asked Questions

How do you click on a 3D model in Three.js?

Use a THREE.Raycaster. Convert the pointer position to normalized device coordinates (x from -1 to 1, y from 1 to -1), call raycaster.setFromCamera(pointer, camera), then raycaster.intersectObject(model, true). If the returned array is not empty, the click hit the model.

Where can I get Minecraft-style 3D models for Three.js?

Sites such as Sketchfab offer free low-poly Minecraft-style models with licenses that allow reuse; check each license. Download or export them as .glb, and Blockbench can export your own blocky models to glTF.

How do I stop OrbitControls drags from counting as clicks?

Store the pointer position on pointerdown and on pointerup only raycast if the pointer moved less than a few pixels. Anything farther is a drag that rotated the camera.

How do I play the walking animation of a GLB model?

Create an AnimationMixer for the model, find the clip by name in gltf.animations, call mixer.clipAction(clip).play(), and call mixer.update(delta) every frame. Use crossFadeTo to blend from idle to walking.

Is this Minecraft Three.js tutorial free?

Yes. It is completely free, not premium. The full code is on the page and opens in a new window with Launch Full Code. The full-screen live demo only asks for a free Google sign-in.

Click the zombie to make it walk.