Building a Third-Person Bow & Arrow System in Three.js

Creating an interactive, real-time 3D experience in the browser has never been easier thanks to Three.js. In this article, we’ll dive into building a complete third-person bow & arrow aiming system — including smooth aiming, bone attachments, character animation, and projectile physics — all running fluidly on the web.

Overview

This project demonstrates:

  • A third-person camera that follows the player
  • Dynamic aiming using raycasting and plane intersection
  • Character animation blending for idle, draw, and shoot
  • Arrow projectile simulation (with optional gravity)
  • Performance optimization through vector reuse

Attaching the Bow to the Character

  • Our character (a Mixamo rigged GLB) already includes hand bones.
  • To attach the bow properly, we find the left-hand bone and compensate for its scale.

const leftHand = character.getObjectByName("mixamorigLeftHand");

bow.position.set(0, 0, 0);

bow.rotation.set(0, Math.PI / 2, 0);

bow.scale.setScalar(1 / leftHand.getWorldScale(tmp).x);

leftHand.add(bow);

Smooth Aiming with Raycasting

To make aiming feel natural, we project a ray from the camera through the mouse cursor and find where it intersects a plane in front of the character.

That intersection point becomes our target aim point.

const raycaster = new THREE.Raycaster();
const plane = new THREE.Plane();
const aimPoint = new THREE.Vector3();
const tmpVec = new THREE.Vector3();


function updateAim(mouseNDC, camera, anchor) {
  const camDir = camera.getWorldDirection(tmpVec);
  const planePoint = anchor.getWorldPosition(new THREE.Vector3()).addScaledVector(camDir, 10);


  plane.setFromNormalAndCoplanarPoint(camDir, planePoint);
  raycaster.setFromCamera(mouseNDC, camera);


  const hit = new THREE.Vector3();
  if (raycaster.ray.intersectPlane(plane, hit)) {
    aimPoint.lerp(hit, 0.1); // smooth motion
  }
}

This gives you smooth, camera-relative aiming that feels consistent at any distance.

Shooting Arrows

When the player clicks, we spawn a new arrow mesh at the bow’s position and give it a velocity toward the aim point.

const arrows = [];
const gravity = -9.8;


function shootArrow(origin, target) {
  const dir = target.clone().sub(origin).normalize();
  const arrow = new THREE.Mesh(
    new THREE.CylinderGeometry(0.02, 0.02, 1),
    new THREE.MeshStandardMaterial({ color: 0x663300 })
  );


  arrow.position.copy(origin);
  arrow.rotation.x = Math.PI / 2;
  arrow.vel = dir.multiplyScalar(40);
  arrow.life = 3;


  arrows.push(arrow);
  scene.add(arrow);
}

Each frame, we update arrow positions and optionally apply gravity:

for (const a of arrows) {
  a.vel.y += gravity * delta;
  a.position.addScaledVector(a.vel, delta);
  a.lookAt(a.position.clone().add(a.vel));
  a.life -= delta;
  if (a.life <= 0) {
    scene.remove(a);
  }
}

Animation Blending

Use a THREE.AnimationMixer to blend between idle, draw, and shoot animations.

function playAnimation(name, fade = 0.25) {
  const newAction = mixer.clipAction(animations[name]);
  newAction.reset().fadeIn(fade).play();


  if (activeAction) activeAction.fadeOut(fade);
  activeAction = newAction;
}

When the player clicks:

  • mousedown → play draw animation
  • mouseup → play shoot animation + spawn arrow
window.addEventListener('mousedown', () => playAnimation('Draw'));
window.addEventListener('mouseup', () => {
  playAnimation('Shoot');
  shootArrow(bow.getWorldPosition(new THREE.Vector3()), aimPoint);
});

Result

After putting everything together, you get a responsive, cinematic aiming experience:

  • Move the character around
  • Hold mouse to draw
  • Release to shoot
  • Arrows fly realistically and disappear after a few seconds

All of this runs fully in the browser with no external physics engine required.

Leave a comment

Your email address will not be published. Required fields are marked *