Caught: 0

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.

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_R or the push strength 26 as 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.