Loading Character Controller.....

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.

mobile-character-controller.html
<!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.