INTERACTIVE
SMILEY PARTICLES
By Shane Brumback · Updated · Free Three.js interaction tutorial
To make 3D objects that react to the mouse in Three.js, raycast the pointer onto a ground plane, push each object away from that point when it gets close, and raycast against the objects on click to catch them. This tutorial builds it as Catch a Smiley: bouncy 3D smileys on a sunny grass field that dodge your hand and pop for points.
Move your mouse toward the smileys on the right and watch them run. Press Live Demo to play full screen.
LIVE DEMO
The smiley playground behind this page is the live demo. Press the button to go full screen, then chase a smiley and click or tap it to pop it and score.
Press Esc or the X to exit.
Introduction
Catch a Smiley is a tiny Three.js game: nine yellow 3D smiley balls bounce around a sunny, grassy playground. When your mouse or finger gets close, they panic, wiggle and dodge away. Click or tap one to pop it into confetti and add a point to your score.
A few definitions before we start. Raycasting means shooting an invisible line from the camera through the pointer and seeing what it hits. A ground plane is a flat math surface at height 0 that turns a 2D pointer into a 3D spot on the grass. Flee behavior is a steering rule that pushes an object away from a threat, stronger the closer it gets. Instancing draws one shape thousands of times in a single draw call, which is how we get a full grass field.
What You'll Learn
Frequently Asked Questions
What is the Catch a Smiley Three.js demo?
It is a small interactive 3D scene made with Three.js: bouncy yellow smiley balls play on a sunny grass field, run away from your mouse or finger, and pop with a burst of confetti and a score point when you tap one. The whole thing is one HTML file of about 250 lines.
How do I make objects run away from the mouse in Three.js?
Raycast the mouse onto a ground plane with Raycaster.setFromCamera and ray.intersectPlane to get a 3D point. For each object, take the vector from that point to the object; if it is shorter than a flee radius, add velocity along it, scaled by how close the mouse is. Damp the velocity every frame for smooth motion.
How do I click a 3D object in Three.js?
Convert the pointer position to normalized device coordinates (-1 to 1), call raycaster.setFromCamera(pointer, camera), then raycaster.intersectObjects(meshes). The first result is the closest object under the pointer. Using pointerdown makes it work for mouse and touch.
How do I put a smiley face on a 3D sphere?
Draw the face on a 2D canvas that is twice as wide as it is tall, wrap it in a THREE.CanvasTexture with SRGBColorSpace and use it as the map of a MeshStandardMaterial on a SphereGeometry. The 2:1 canvas matches the sphere's UV layout, so the face is not stretched; draw it at a quarter of the width to face +Z.
Why is the grass static instead of waving in the wind?
Static grass keeps the focus on the moving smileys and is cheaper: the InstancedMesh matrices are written once and the shader just colors each blade, so there is no per-frame vertex work. For a wind version, see the Endless Procedural Grass Field tutorial.
Does the Catch a Smiley demo work on phones?
Yes. It uses pointer events, which cover touch, and the canvas has touch-action none in the full code so dragging a finger moves your hand ring instead of scrolling. Tap a smiley to catch it. The pixel ratio is capped at 2 to keep it smooth.
Is this Three.js tutorial free?
Yes. The tutorial and the full code are free on this page: copy it into an .html file or press Launch to run it in a new tab. The full-screen live demo just asks for a quick free Google sign-in.
How to Build It, Step by Step
Each step below is one piece of the same file, in order. Put them together (or grab the full code) and you get exactly the playground behind this page.
Step 1: Set up the scene, camera and renderer
Every Three.js project needs a scene (the world), a camera (your eye, lifted up and back so you look down onto the playground) and a renderer that draws it onto a canvas. We turn on soft shadows and filmic tone mapping here so the sunlight looks warm instead of flat.
// STEP 1: Setup - scene, camera and renderer with soft shadows
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 3.8, 13);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
Step 2: Paint a sunny sky and light it
The sky is a three-color gradient (deep blue, light blue, warm haze) painted on a tiny canvas and used as scene.background. Fog in a soft haze color hides the far edge of the ground. A HemisphereLight fills everything with sky-blue light from above and grass-green bounce light from below, and one DirectionalLight is the sun, casting soft shadows with a 2048 shadow map and a blur radius.
// STEP 2: Sunny gradient sky, fog, hemisphere light and a sun
const sky = document.createElement('canvas');
sky.width = 2; sky.height = 512;
const sctx = sky.getContext('2d');
const grad = sctx.createLinearGradient(0, 0, 0, 512);
grad.addColorStop(0, '#3f8fe8'); // deep blue overhead
grad.addColorStop(0.5, '#b4dcf5'); // light blue
grad.addColorStop(1, '#e9f2e6'); // warm haze at the horizon (same as the fog)
sctx.fillStyle = grad;
sctx.fillRect(0, 0, 2, 512);
const skyTex = new THREE.CanvasTexture(sky);
skyTex.colorSpace = THREE.SRGBColorSpace;
scene.background = skyTex;
scene.fog = new THREE.Fog('#e9f2e6', 22, 48); // the far grass melts into the haze
scene.add(new THREE.HemisphereLight('#cfe8ff', '#4a7a2a', 1.4)); // sky blue from above, grass green from below
const sun = new THREE.DirectionalLight('#fff3d6', 2.6);
sun.position.set(-8, 14, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -12; sun.shadow.camera.right = 12;
sun.shadow.camera.top = 12; sun.shadow.camera.bottom = -12;
sun.shadow.radius = 5;
sun.shadow.bias = -0.0005;
scene.add(sun);
Step 3: Grow a static grass ground
The ground is a big green plane that receives the smileys' shadows. On top we scatter 26,000 little grass tufts with an InstancedMesh: one tuft shape stamped thousands of times in a single draw call, the same trick as the Endless Procedural Grass Field tutorial. The difference: this grass is static. The shader only shades each blade from dark root to bright tip and blends in fog; there is no wind, so the matrices are written once and never touched again.
// STEP 3: A grass ground - a green plane plus thousands of STATIC instanced blades
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.MeshStandardMaterial({ color: '#4f8a2b', roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
function makeTuft(width, height) { // 4 fanned blades = one grass tuft
const verts = [], uvs = [];
for (let b = 0; b < 4; b++) {
const a = (b / 4) * Math.PI * 2, c = Math.cos(a), s = Math.sin(a);
const lean = height * 0.35;
verts.push(-width / 2 * c, 0, -width / 2 * s, width / 2 * c, 0, width / 2 * s, c * lean, height, s * lean);
uvs.push(0, 0, 1, 0, 0.5, 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
const grassMat = new THREE.ShaderMaterial({ // dark at the root, bright at the tip, NO wind
uniforms: { uBase: { value: new THREE.Color('#2f5f17') }, uTip: { value: new THREE.Color('#a6d64a') },
fogColor: { value: scene.fog.color }, fogNear: { value: scene.fog.near }, fogFar: { value: scene.fog.far } },
vertexShader: `
varying vec2 vUv; varying float vDepth;
void main() {
vUv = uv;
vec4 mv = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
vDepth = -mv.z;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
uniform vec3 uBase; uniform vec3 uTip; uniform vec3 fogColor; uniform float fogNear; uniform float fogFar;
varying vec2 vUv; varying float vDepth;
void main() {
vec3 col = mix(uBase, uTip, vUv.y);
col = mix(col, fogColor, smoothstep(fogNear, fogFar, vDepth));
gl_FragColor = vec4(col, 1.0);
}`,
side: THREE.DoubleSide
});
const GRASS = 26000, GRASS_R = 30;
const grass = new THREE.InstancedMesh(makeTuft(0.06, 0.36), grassMat, GRASS);
const dummy = new THREE.Object3D();
for (let i = 0; i < GRASS; i++) {
const r = Math.sqrt(Math.random()) * GRASS_R, a = Math.random() * Math.PI * 2;
dummy.position.set(Math.cos(a) * r, 0, Math.sin(a) * r);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.setScalar(0.6 + Math.random() * 0.8);
dummy.updateMatrix();
grass.setMatrixAt(i, dummy.matrix);
}
scene.add(grass); // built once, never animated
Step 4: Make a smiley texture and 3D smiley balls
Instead of flat sprites, each smiley is a sphere. We draw the face (eyes, smile and rosy cheeks) on a 2:1 canvas so it wraps around the sphere without stretching, and put it at u = 0.25, the side of a SphereGeometry that faces +Z. Nine smileys share one geometry and texture, each with its own position, velocity, wander goal and bounce phase.
// STEP 4: A smiley texture drawn on a canvas, wrapped on 3D spheres
function smileyTexture() {
const c = document.createElement('canvas');
c.width = 512; c.height = 256; // 2:1 so the face is not stretched on the sphere
const g = c.getContext('2d');
g.fillStyle = '#ffd23f'; g.fillRect(0, 0, 512, 256);
const fx = 128, fy = 128; // u = 0.25 is the side facing +Z
g.fillStyle = '#3a2208';
g.beginPath(); g.ellipse(fx - 36, fy - 30, 13, 23, 0, 0, Math.PI * 2); g.fill();
g.beginPath(); g.ellipse(fx + 36, fy - 30, 13, 23, 0, 0, Math.PI * 2); g.fill();
g.strokeStyle = '#3a2208'; g.lineWidth = 14; g.lineCap = 'round';
g.beginPath(); g.arc(fx, fy + 4, 58, 0.15 * Math.PI, 0.85 * Math.PI); g.stroke();
g.fillStyle = 'rgba(255,120,90,0.45)'; // rosy cheeks
g.beginPath(); g.arc(fx - 68, fy + 18, 15, 0, Math.PI * 2); g.fill();
g.beginPath(); g.arc(fx + 68, fy + 18, 15, 0, Math.PI * 2); g.fill();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 4;
return t;
}
const faceTex = smileyTexture();
const ballGeo = new THREE.SphereGeometry(0.6, 48, 32);
const ARENA = 5.5, COUNT = 9;
const smileys = [];
function randomSpot(v) {
const r = Math.sqrt(Math.random()) * ARENA, a = Math.random() * Math.PI * 2;
return v.set(Math.cos(a) * r, 0, Math.sin(a) * r);
}
for (let i = 0; i < COUNT; i++) {
const mesh = new THREE.Mesh(ballGeo, new THREE.MeshStandardMaterial({ map: faceTex, roughness: 0.35 }));
mesh.castShadow = true;
scene.add(mesh);
const s = { mesh, pos: randomSpot(new THREE.Vector3()), vel: new THREE.Vector3(),
goal: randomSpot(new THREE.Vector3()), phase: Math.random() * 6, fear: 0, pop: 0,
flee: new THREE.Vector3(), lastWave: 0 };
mesh.position.copy(s.pos);
smileys.push(s);
}
Step 5: Raycast the mouse onto the ground
To know where your "hand" is, we turn the mouse or finger position into normalized device coordinates, then cast a ray from the camera with Raycaster.setFromCamera and intersect it with a flat THREE.Plane at ground level. That point drives a soft white ring on the grass. The listeners are on renderer.domElement only, so they never block links elsewhere on a page.
// STEP 5: Mouse/touch raycast onto the ground plane
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(9, 9); // off-screen until the pointer moves
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const hand = new THREE.Vector3(999, 0, 999); // where the pointer touches the ground
let handActive = false;
function setPointer(x, y) {
pointer.x = (x / window.innerWidth) * 2 - 1;
pointer.y = -(y / window.innerHeight) * 2 + 1;
handActive = true;
}
renderer.domElement.addEventListener('pointermove', (e) => setPointer(e.clientX, e.clientY));
renderer.domElement.addEventListener('pointerleave', () => { handActive = false; });
// A soft glowing ring on the grass shows where your "hand" is
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.75, 48),
new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.55, depthWrite: false })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.03;
scene.add(ring);
Step 6: Make the smileys flee, bounce and wiggle
Each frame a smiley drifts toward a random wander goal. If your hand is within 3.2 units, it reacts instantly and races straight away from you, up to about 6.5 units per second when you are right on top of it, with a small nudge away on each landing. Velocity is damped every frame so they settle again once you leave, and a hard fence clamps them to the playground so they slide along the edge instead of getting stuck. Scared smileys keep the same bounce rhythm but hop slightly higher, squash a little when they land, turn to face you and sway gently.
// STEP 6: Flee behavior - wander, dodge away from the hand, bounce and wiggle
const FLEE_R = 3.2, away = new THREE.Vector3(), toGoal = new THREE.Vector3();
function updateSmiley(s, dt, t) {
if (s.pop > 0) return; // popped smileys wait to respawn
toGoal.subVectors(s.goal, s.pos).setY(0);
if (toGoal.length() < 0.5) randomSpot(s.goal);
s.vel.addScaledVector(toGoal.normalize(), 1.2 * dt); // gentle wander
away.subVectors(s.pos, hand).setY(0);
const d = away.length();
const scared = handActive && d < FLEE_R ? 1 - d / FLEE_R : 0;
if (scared > 0) {
s.flee.copy(away.normalize()); // remember which way is "away"
s.vel.addScaledVector(away, 40 * scared * dt); // quick shove away from the hand
const minRun = 2 + 4.5 * scared; // fast, smooth run (~6.5 units/sec up close)
const out = s.vel.dot(away);
if (out < minRun) s.vel.addScaledVector(away, (minRun - out) * Math.min(1, dt * 10)); // react instantly
}
s.fear += (scared - s.fear) * Math.min(1, dt * 6); // panic rises fast
const speed = 3; // same calm bounce rhythm, scared or not
const wave = Math.sin(t * speed + s.phase);
if (wave * s.lastWave < 0 && s.fear > 0.05) s.vel.addScaledVector(s.flee, 0.6 * s.fear); // small nudge away on each landing
s.lastWave = wave;
s.vel.multiplyScalar(Math.exp(-2.2 * dt)); // damping: they settle once you leave
s.pos.addScaledVector(s.vel, dt);
const r = Math.hypot(s.pos.x, s.pos.z); // hard fence: clamp to the field and slide along the edge
if (r > ARENA) {
const nx = s.pos.x / r, nz = s.pos.z / r;
s.pos.x = nx * ARENA; s.pos.z = nz * ARENA;
const vn = s.vel.x * nx + s.vel.z * nz;
if (vn > 0) { s.vel.x -= vn * nx; s.vel.z -= vn * nz; } // drop only the outward part
}
const hop = Math.abs(wave);
const m = s.mesh;
m.position.set(s.pos.x, 0.6 + hop * (0.5 + s.fear * 0.25), s.pos.z);
const squash = 1 - (1 - hop) * 0.18; // squash a little when touching down
m.scale.set(1 / Math.sqrt(squash), squash, 1 / Math.sqrt(squash));
m.lookAt(camera.position.x, m.position.y, camera.position.z); // face you
m.rotateZ(Math.sin(t * 4 + s.phase) * 0.08 * s.fear); // tiny, slow nervous sway
}
Step 7: Catch a smiley: pop and score
On pointerdown we raycast against the smiley meshes. If you hit one, it pops: it hides, sixteen little yellow balls burst out with gravity, a short WebAudio blip plays, and the score counter goes up with a bump animation. After 1.4 seconds the smiley respawns somewhere new. Touch works the same way, because pointer events cover mouse, pen and fingers.
// STEP 7: Catch and score - tap a smiley to pop it
const hud = document.getElementById('score');
let score = 0;
const bits = [];
const bitGeo = new THREE.SphereGeometry(0.09, 8, 6);
const bitMat = new THREE.MeshStandardMaterial({ color: '#ffd23f', emissive: '#ff9f1c', emissiveIntensity: 0.4 });
let audio = null;
function popSound() {
audio = audio || new (window.AudioContext || window.webkitAudioContext)();
const o = audio.createOscillator(), g = audio.createGain();
o.frequency.setValueAtTime(700, audio.currentTime);
o.frequency.exponentialRampToValueAtTime(140, audio.currentTime + 0.15);
g.gain.setValueAtTime(0.25, audio.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + 0.18);
o.connect(g).connect(audio.destination);
o.start(); o.stop(audio.currentTime + 0.2);
}
function pop(s) {
s.pop = 1.4; // seconds until it comes back
s.mesh.visible = false;
for (let i = 0; i < 16; i++) { // a confetti burst of little yellow balls
const b = new THREE.Mesh(bitGeo, bitMat);
b.position.copy(s.mesh.position);
b.userData.v = new THREE.Vector3((Math.random() - 0.5) * 6, 3 + Math.random() * 4, (Math.random() - 0.5) * 6);
b.userData.life = 0.8;
scene.add(b); bits.push(b);
}
score++;
if (hud) { hud.textContent = 'Caught: ' + score; hud.classList.remove('bump'); void hud.offsetWidth; hud.classList.add('bump'); }
try { popSound(); } catch (e) {}
}
renderer.domElement.addEventListener('pointerdown', (e) => {
setPointer(e.clientX, e.clientY);
raycaster.setFromCamera(pointer, camera);
const live = smileys.filter((s) => s.pop <= 0).map((s) => s.mesh);
const hit = raycaster.intersectObjects(live)[0];
if (hit) pop(smileys.find((s) => s.mesh === hit.object));
});
Step 8: Run the animation loop
The loop runs every frame with requestAnimationFrame. It updates the hand point, moves every smiley, counts down respawns, lets the confetti fall and shrink, and renders. Clamping dt to 0.05 seconds stops a big jump if the tab was in the background. The resize listener keeps it sharp on any screen.
// STEP 8: The animation loop
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05), t = clock.elapsedTime;
raycaster.setFromCamera(pointer, camera); // where is the hand on the grass?
if (handActive && raycaster.ray.intersectPlane(groundPlane, hand)) {
ring.position.x = hand.x; ring.position.z = hand.z;
}
ring.visible = handActive;
for (const s of smileys) {
if (s.pop > 0) {
s.pop -= dt;
if (s.pop <= 0) { randomSpot(s.pos); s.vel.set(0, 0, 0); s.mesh.visible = true; }
}
updateSmiley(s, dt, t);
}
for (let i = bits.length - 1; i >= 0; i--) { // falling confetti
const b = bits[i];
b.userData.v.y -= 12 * dt;
b.position.addScaledVector(b.userData.v, dt);
b.userData.life -= dt;
b.scale.setScalar(Math.max(0.01, b.userData.life / 0.8));
if (b.userData.life <= 0) { scene.remove(b); bits.splice(i, 1); }
}
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
The Full Code
Copy this into a file named index.html and open it in any modern browser, or press Launch to play it in a new tab right now. It is the same scene running behind this page.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Catch a Smiley - Three.js</title>
<style>
body { margin: 0; overflow: hidden; font-family: system-ui, sans-serif; }
canvas { display: block; touch-action: none; }
#score { position: fixed; top: 16px; left: 16px; padding: 10px 18px; border-radius: 999px; background: rgba(255,255,255,0.85); color: #3a2208; font-weight: 800; font-size: 20px; transition: transform .15s; }
#score.bump { animation: bump .3s; }
@keyframes bump { 50% { transform: scale(1.3); } }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
</head>
<body>
<div id="score">Caught: 0</div>
<script type="module">
import * as THREE from 'three';
// STEP 1: Setup - scene, camera and renderer with soft shadows
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 3.8, 13);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
// STEP 2: Sunny gradient sky, fog, hemisphere light and a sun
const sky = document.createElement('canvas');
sky.width = 2; sky.height = 512;
const sctx = sky.getContext('2d');
const grad = sctx.createLinearGradient(0, 0, 0, 512);
grad.addColorStop(0, '#3f8fe8'); // deep blue overhead
grad.addColorStop(0.5, '#b4dcf5'); // light blue
grad.addColorStop(1, '#e9f2e6'); // warm haze at the horizon (same as the fog)
sctx.fillStyle = grad;
sctx.fillRect(0, 0, 2, 512);
const skyTex = new THREE.CanvasTexture(sky);
skyTex.colorSpace = THREE.SRGBColorSpace;
scene.background = skyTex;
scene.fog = new THREE.Fog('#e9f2e6', 22, 48); // the far grass melts into the haze
scene.add(new THREE.HemisphereLight('#cfe8ff', '#4a7a2a', 1.4)); // sky blue from above, grass green from below
const sun = new THREE.DirectionalLight('#fff3d6', 2.6);
sun.position.set(-8, 14, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -12; sun.shadow.camera.right = 12;
sun.shadow.camera.top = 12; sun.shadow.camera.bottom = -12;
sun.shadow.radius = 5;
sun.shadow.bias = -0.0005;
scene.add(sun);
// STEP 3: A grass ground - a green plane plus thousands of STATIC instanced blades
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.MeshStandardMaterial({ color: '#4f8a2b', roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
function makeTuft(width, height) { // 4 fanned blades = one grass tuft
const verts = [], uvs = [];
for (let b = 0; b < 4; b++) {
const a = (b / 4) * Math.PI * 2, c = Math.cos(a), s = Math.sin(a);
const lean = height * 0.35;
verts.push(-width / 2 * c, 0, -width / 2 * s, width / 2 * c, 0, width / 2 * s, c * lean, height, s * lean);
uvs.push(0, 0, 1, 0, 0.5, 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
const grassMat = new THREE.ShaderMaterial({ // dark at the root, bright at the tip, NO wind
uniforms: { uBase: { value: new THREE.Color('#2f5f17') }, uTip: { value: new THREE.Color('#a6d64a') },
fogColor: { value: scene.fog.color }, fogNear: { value: scene.fog.near }, fogFar: { value: scene.fog.far } },
vertexShader: `
varying vec2 vUv; varying float vDepth;
void main() {
vUv = uv;
vec4 mv = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
vDepth = -mv.z;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
uniform vec3 uBase; uniform vec3 uTip; uniform vec3 fogColor; uniform float fogNear; uniform float fogFar;
varying vec2 vUv; varying float vDepth;
void main() {
vec3 col = mix(uBase, uTip, vUv.y);
col = mix(col, fogColor, smoothstep(fogNear, fogFar, vDepth));
gl_FragColor = vec4(col, 1.0);
}`,
side: THREE.DoubleSide
});
const GRASS = 26000, GRASS_R = 30;
const grass = new THREE.InstancedMesh(makeTuft(0.06, 0.36), grassMat, GRASS);
const dummy = new THREE.Object3D();
for (let i = 0; i < GRASS; i++) {
const r = Math.sqrt(Math.random()) * GRASS_R, a = Math.random() * Math.PI * 2;
dummy.position.set(Math.cos(a) * r, 0, Math.sin(a) * r);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.setScalar(0.6 + Math.random() * 0.8);
dummy.updateMatrix();
grass.setMatrixAt(i, dummy.matrix);
}
scene.add(grass); // built once, never animated
// STEP 4: A smiley texture drawn on a canvas, wrapped on 3D spheres
function smileyTexture() {
const c = document.createElement('canvas');
c.width = 512; c.height = 256; // 2:1 so the face is not stretched on the sphere
const g = c.getContext('2d');
g.fillStyle = '#ffd23f'; g.fillRect(0, 0, 512, 256);
const fx = 128, fy = 128; // u = 0.25 is the side facing +Z
g.fillStyle = '#3a2208';
g.beginPath(); g.ellipse(fx - 36, fy - 30, 13, 23, 0, 0, Math.PI * 2); g.fill();
g.beginPath(); g.ellipse(fx + 36, fy - 30, 13, 23, 0, 0, Math.PI * 2); g.fill();
g.strokeStyle = '#3a2208'; g.lineWidth = 14; g.lineCap = 'round';
g.beginPath(); g.arc(fx, fy + 4, 58, 0.15 * Math.PI, 0.85 * Math.PI); g.stroke();
g.fillStyle = 'rgba(255,120,90,0.45)'; // rosy cheeks
g.beginPath(); g.arc(fx - 68, fy + 18, 15, 0, Math.PI * 2); g.fill();
g.beginPath(); g.arc(fx + 68, fy + 18, 15, 0, Math.PI * 2); g.fill();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 4;
return t;
}
const faceTex = smileyTexture();
const ballGeo = new THREE.SphereGeometry(0.6, 48, 32);
const ARENA = 5.5, COUNT = 9;
const smileys = [];
function randomSpot(v) {
const r = Math.sqrt(Math.random()) * ARENA, a = Math.random() * Math.PI * 2;
return v.set(Math.cos(a) * r, 0, Math.sin(a) * r);
}
for (let i = 0; i < COUNT; i++) {
const mesh = new THREE.Mesh(ballGeo, new THREE.MeshStandardMaterial({ map: faceTex, roughness: 0.35 }));
mesh.castShadow = true;
scene.add(mesh);
const s = { mesh, pos: randomSpot(new THREE.Vector3()), vel: new THREE.Vector3(),
goal: randomSpot(new THREE.Vector3()), phase: Math.random() * 6, fear: 0, pop: 0,
flee: new THREE.Vector3(), lastWave: 0 };
mesh.position.copy(s.pos);
smileys.push(s);
}
// STEP 5: Mouse/touch raycast onto the ground plane
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(9, 9); // off-screen until the pointer moves
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const hand = new THREE.Vector3(999, 0, 999); // where the pointer touches the ground
let handActive = false;
function setPointer(x, y) {
pointer.x = (x / window.innerWidth) * 2 - 1;
pointer.y = -(y / window.innerHeight) * 2 + 1;
handActive = true;
}
renderer.domElement.addEventListener('pointermove', (e) => setPointer(e.clientX, e.clientY));
renderer.domElement.addEventListener('pointerleave', () => { handActive = false; });
// A soft glowing ring on the grass shows where your "hand" is
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.55, 0.75, 48),
new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.55, depthWrite: false })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.03;
scene.add(ring);
// STEP 6: Flee behavior - wander, dodge away from the hand, bounce and wiggle
const FLEE_R = 3.2, away = new THREE.Vector3(), toGoal = new THREE.Vector3();
function updateSmiley(s, dt, t) {
if (s.pop > 0) return; // popped smileys wait to respawn
toGoal.subVectors(s.goal, s.pos).setY(0);
if (toGoal.length() < 0.5) randomSpot(s.goal);
s.vel.addScaledVector(toGoal.normalize(), 1.2 * dt); // gentle wander
away.subVectors(s.pos, hand).setY(0);
const d = away.length();
const scared = handActive && d < FLEE_R ? 1 - d / FLEE_R : 0;
if (scared > 0) {
s.flee.copy(away.normalize()); // remember which way is "away"
s.vel.addScaledVector(away, 40 * scared * dt); // quick shove away from the hand
const minRun = 2 + 4.5 * scared; // fast, smooth run (~6.5 units/sec up close)
const out = s.vel.dot(away);
if (out < minRun) s.vel.addScaledVector(away, (minRun - out) * Math.min(1, dt * 10)); // react instantly
}
s.fear += (scared - s.fear) * Math.min(1, dt * 6); // panic rises fast
const speed = 3; // same calm bounce rhythm, scared or not
const wave = Math.sin(t * speed + s.phase);
if (wave * s.lastWave < 0 && s.fear > 0.05) s.vel.addScaledVector(s.flee, 0.6 * s.fear); // small nudge away on each landing
s.lastWave = wave;
s.vel.multiplyScalar(Math.exp(-2.2 * dt)); // damping: they settle once you leave
s.pos.addScaledVector(s.vel, dt);
const r = Math.hypot(s.pos.x, s.pos.z); // hard fence: clamp to the field and slide along the edge
if (r > ARENA) {
const nx = s.pos.x / r, nz = s.pos.z / r;
s.pos.x = nx * ARENA; s.pos.z = nz * ARENA;
const vn = s.vel.x * nx + s.vel.z * nz;
if (vn > 0) { s.vel.x -= vn * nx; s.vel.z -= vn * nz; } // drop only the outward part
}
const hop = Math.abs(wave);
const m = s.mesh;
m.position.set(s.pos.x, 0.6 + hop * (0.5 + s.fear * 0.25), s.pos.z);
const squash = 1 - (1 - hop) * 0.18; // squash a little when touching down
m.scale.set(1 / Math.sqrt(squash), squash, 1 / Math.sqrt(squash));
m.lookAt(camera.position.x, m.position.y, camera.position.z); // face you
m.rotateZ(Math.sin(t * 4 + s.phase) * 0.08 * s.fear); // tiny, slow nervous sway
}
// STEP 7: Catch and score - tap a smiley to pop it
const hud = document.getElementById('score');
let score = 0;
const bits = [];
const bitGeo = new THREE.SphereGeometry(0.09, 8, 6);
const bitMat = new THREE.MeshStandardMaterial({ color: '#ffd23f', emissive: '#ff9f1c', emissiveIntensity: 0.4 });
let audio = null;
function popSound() {
audio = audio || new (window.AudioContext || window.webkitAudioContext)();
const o = audio.createOscillator(), g = audio.createGain();
o.frequency.setValueAtTime(700, audio.currentTime);
o.frequency.exponentialRampToValueAtTime(140, audio.currentTime + 0.15);
g.gain.setValueAtTime(0.25, audio.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + 0.18);
o.connect(g).connect(audio.destination);
o.start(); o.stop(audio.currentTime + 0.2);
}
function pop(s) {
s.pop = 1.4; // seconds until it comes back
s.mesh.visible = false;
for (let i = 0; i < 16; i++) { // a confetti burst of little yellow balls
const b = new THREE.Mesh(bitGeo, bitMat);
b.position.copy(s.mesh.position);
b.userData.v = new THREE.Vector3((Math.random() - 0.5) * 6, 3 + Math.random() * 4, (Math.random() - 0.5) * 6);
b.userData.life = 0.8;
scene.add(b); bits.push(b);
}
score++;
if (hud) { hud.textContent = 'Caught: ' + score; hud.classList.remove('bump'); void hud.offsetWidth; hud.classList.add('bump'); }
try { popSound(); } catch (e) {}
}
renderer.domElement.addEventListener('pointerdown', (e) => {
setPointer(e.clientX, e.clientY);
raycaster.setFromCamera(pointer, camera);
const live = smileys.filter((s) => s.pop <= 0).map((s) => s.mesh);
const hit = raycaster.intersectObjects(live)[0];
if (hit) pop(smileys.find((s) => s.mesh === hit.object));
});
// STEP 8: The animation loop
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05), t = clock.elapsedTime;
raycaster.setFromCamera(pointer, camera); // where is the hand on the grass?
if (handActive && raycaster.ray.intersectPlane(groundPlane, hand)) {
ring.position.x = hand.x; ring.position.z = hand.z;
}
ring.visible = handActive;
for (const s of smileys) {
if (s.pop > 0) {
s.pop -= dt;
if (s.pop <= 0) { randomSpot(s.pos); s.vel.set(0, 0, 0); s.mesh.visible = true; }
}
updateSmiley(s, dt, t);
}
for (let i = bits.length - 1; i >= 0; i--) { // falling confetti
const b = bits[i];
b.userData.v.y -= 12 * dt;
b.position.addScaledVector(b.userData.v, dt);
b.userData.life -= dt;
b.scale.setScalar(Math.max(0.01, b.userData.life / 0.8));
if (b.userData.life <= 0) { scene.remove(b); bits.splice(i, 1); }
}
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
What To Try Next
- Add a 30-second timer and show a "best score" saved in
localStorage. - Make each catch a little harder: raise
FLEE_Ror the push strength26as the score climbs. - Give some smileys a different face texture (wink, surprised) worth bonus points.
- Swap the ring for a little 3D net that follows your hand.
- Want waving grass? Learn the wind shader in Endless Procedural Grass Field, or go deeper on clicks in Selecting a 3D Cube with Raycasters.
Beginner Three.js Tutorials
Keep learning: more beginner-friendly Three.js tutorials, one small idea at a time.

Rendering a 3D Cube
The absolute basics of putting a cube on screen.

Adding Lighting to a Scene
Ambient, directional, point and spot lights with shadows.

Creating and Animating Simple 3D Objects
Make shapes and bring them to life with simple motion.

GLSL Gradient Shader Cube
Your next step: color a cube with a custom shader.

How to Program a 3D Menu
A beginner-friendly ring of clickable image panels.
SEE 3D AND 2D BROWSER GAMES IN ACTION
Super Soldier Battle
A free multiplayer 3D FPS that runs right in your browser — built with Three.js and WebGL.
AI Tic-Tac-Toe
Play tic-tac-toe against an unbeatable AI opponent. Free, no download, plays in the browser.
Zombie Attack
Survive waves of zombies in this free 3D browser shooter built with Three.js. No download, plays instantly.
Play now →
Space Wow
Blast through space in this free 3D Three.js arcade shooter. Free, no download, plays right in your browser.
Play now →