How the Mobile Character Controller Works
Scroll up and drag the glowing joystick to walk the character around. The controller works by
drawing an on-screen joystick (an outer circle with a draggable inner circle), reading the drag
direction as a normalized vector, rotating the 3D model to face that direction, and playing a
walk animation through a Three.js AnimationMixer. On desktop it responds to the mouse;
on mobile it responds to touch. The full production version adds sound, model swapping, and a
following camera, but the core loop below is everything you need to move an animated character.
Full Working Code (Copy & Paste)
Save this as an .html file in a folder that also contains the Three.js
build/ and jsm/ module folders plus a rigged character.glb
model with idle and walk animation clips. Open it in a browser and drag
the joystick to move.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Three.js Mobile Character Controller</title>
<style>
body { margin: 0; overflow: hidden; background: #05101c; }
/* On-screen joystick */
#joystick {
position: fixed; bottom: 6%; left: 50%; transform: translateX(-50%);
width: 22vw; height: 22vw; max-width: 90px; max-height: 90px;
border-radius: 50%; background: rgba(255,255,255,0.25);
box-shadow: 0 0 9px 4px #747DE8; z-index: 10; touch-action: none;
}
#stick {
position: absolute; top: 50%; left: 50%;
width: 60%; height: 60%; border-radius: 50%;
transform: translate(-50%, -50%); cursor: pointer;
background: radial-gradient(circle at 50% 120%,
rgba(129,232,246,0.95), rgba(5,81,148,0.85) 80%);
}
</style>
<script type="importmap">
{ "imports": {
"three": "./build/three.module.js",
"three/addons/": "./jsm/"
} }
</script>
</head>
<body>
<div id="joystick"><div id="stick"></div></div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- Scene, camera, renderer ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05101c);
const camera = new THREE.PerspectiveCamera(
50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 4, 8);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
// --- Lighting + ground ---
scene.add(new THREE.AmbientLight(0xffffff, 0.9));
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
dir.position.set(5, 10, 7);
scene.add(dir);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.MeshStandardMaterial({ color: 0x0e2233 }));
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// --- Load an animated character ---
let model, mixer, idleAction, walkAction;
const clock = new THREE.Clock();
new GLTFLoader().load('character.glb', (gltf) => {
model = gltf.scene;
scene.add(model);
mixer = new THREE.AnimationMixer(model);
const clips = gltf.animations;
const idle = THREE.AnimationClip.findByName(clips, 'idle') || clips[0];
const walk = THREE.AnimationClip.findByName(clips, 'walk') || clips[1] || clips[0];
idleAction = mixer.clipAction(idle);
walkAction = mixer.clipAction(walk);
idleAction.play();
});
// --- Joystick input ---
const joystick = document.getElementById('joystick');
const stick = document.getElementById('stick');
let moveX = 0, moveZ = 0, active = false;
function setDirection(clientX, clientY) {
const r = joystick.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// Normalized -1..1 direction from joystick center
let dx = (clientX - cx) / (r.width / 2);
let dy = (clientY - cy) / (r.height / 2);
const len = Math.hypot(dx, dy) || 1;
if (len > 1) { dx /= len; dy /= len; }
moveX = dx; moveZ = dy;
stick.style.left = (50 + dx * 30) + '%';
stick.style.top = (50 + dy * 30) + '%';
}
function startMove(x, y) {
active = true;
if (walkAction) { idleAction.fadeOut(0.2); walkAction.reset().fadeIn(0.2).play(); }
setDirection(x, y);
}
function endMove() {
active = false;
moveX = moveZ = 0;
stick.style.left = '50%';
stick.style.top = '50%';
if (walkAction) { walkAction.fadeOut(0.2); idleAction.reset().fadeIn(0.2).play(); }
}
// Mouse
joystick.addEventListener('mousedown', (e) => startMove(e.clientX, e.clientY));
window.addEventListener('mousemove', (e) => { if (active) setDirection(e.clientX, e.clientY); });
window.addEventListener('mouseup', endMove);
// Touch
joystick.addEventListener('touchstart', (e) => startMove(e.touches[0].clientX, e.touches[0].clientY));
window.addEventListener('touchmove', (e) => { if (active) setDirection(e.touches[0].clientX, e.touches[0].clientY); });
window.addEventListener('touchend', endMove);
// --- Animation loop ---
const speed = 3; // units per second
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
if (model && active && (moveX || moveZ)) {
// Face the movement direction and step forward
const angle = Math.atan2(moveX, moveZ);
model.rotation.y = angle;
model.position.x += Math.sin(angle) * speed * delta;
model.position.z += Math.cos(angle) * speed * delta;
controls.target.copy(model.position).y += 1;
}
controls.update();
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>
1. Draw the joystick
The joystick is pure HTML/CSS: an outer circle fixed near the bottom of the screen and an inner
#stick that you drag. touch-action: none stops the browser from scrolling
the page while you drag on mobile.
2. Read the direction vector
setDirection() measures where you dragged relative to the joystick center and converts
it into a normalized (dx, dy) pair between -1 and 1. That vector both nudges the visible
stick and drives the character's heading.
3. Rotate and move the model
In the animation loop, Math.atan2(moveX, moveZ) turns the joystick vector into a facing
angle. The model rotates to that angle and steps forward along it, scaled by delta so
movement is frame-rate independent.
4. Blend idle and walk animations
An AnimationMixer plays the idle clip at rest and cross-fades into the
walk clip when you start dragging, then fades back to idle on release — the same
technique the live demo above uses.