Three.js Mobile On-Screen Character Controller
A complete beginner-friendly tutorial for building a touch joystick that walks and rotates an animated 3D character in the browser — with a following camera and idle/walk animation blending. Drag the glowing joystick on the scene above to try it, then copy the full code below.
Frequently Asked Questions
How do you make an on-screen joystick for mobile games in Three.js?
Create an outer circle and a draggable inner circle with HTML and CSS, then listen for touchstart/touchmove (and mousedown/mousemove on desktop). On each move, measure the drag position relative to the joystick center and convert it into a normalized direction vector between -1 and 1. Use that vector to rotate and move your 3D character. Set touch-action: none so the page does not scroll while dragging.
How do you move and rotate a 3D character with a joystick vector?
Turn the joystick's (x, y) vector into a heading angle with Math.atan2(x, y). Set the model's rotation.y to that angle so it faces the drag direction, then step it forward along that heading using sin and cos of the angle, scaled by the frame delta so movement is frame-rate independent.
How do you blend idle and walk animations in Three.js?
Load a rigged GLB model with GLTFLoader and create an AnimationMixer. Get a clipAction for each clip (idle, walk). Play idle at rest, and when the joystick is active call walkAction.reset().fadeIn(0.2).play() while fading out idle. On release, fade back to idle. Call mixer.update(delta) every frame in the animation loop.
How do you make the camera follow the character?
Each frame, lerp the camera toward the character's position plus a fixed offset (for example 0, 4, 8), and point the OrbitControls target at the model. This keeps the character centered as it walks around the scene while still allowing the player to orbit the view.
Does this Three.js character controller work on both mobile and desktop?
Yes. The joystick listens for touch events on mobile and mouse events on desktop, so the same code drives the character with a finger or a mouse. Because the movement is delta-scaled, it runs smoothly across different frame rates and devices.
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.