Tap-to-move is the first input scheme most 3D web games implement and the first place they get subtly wrong. Not broken — wrong in ways that feel like the game is cheap.
The naive version is four lines and it mostly works:
canvas.addEventListener('pointerdown', e => {
const ndc = toNDC(e.clientX, e.clientY);
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground)[0];
if (hit) player.target.copy(hit.point);
});
Here is what that misses, roughly in the order you'll discover it.
Normalised device coordinates need the canvas, not the window
clientX is relative to the viewport. The raycaster wants coordinates relative to the canvas, in the range −1 to 1, with y flipped. If your canvas is inset, letterboxed, or sits below a header, using window dimensions puts every tap at a small offset from where the player aimed — and it will feel like input lag rather than a mapping error.
const r = canvas.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width) * 2 - 1;
const y = -((e.clientY - r.top) / r.height) * 2 + 1;
Recompute the rect on resize and on orientation change, or cache it and invalidate — reading it per tap is fine, reading it per frame is not.
Raycast a plane, not the ground mesh
Intersecting the visible ground works until the ground has holes, decorative geometry, or a mesh whose collider does not match its silhouette. Then taps near edges do nothing, which reads as unresponsiveness.
Raycast a mathematical plane at the gameplay height instead. It always hits, it costs nothing, and it decouples input from art.
pointerdown, and only pointer events
Not mousedown plus a touch path bolted on later. pointerdown covers mouse, touch and pen in one code path. Also set touch-action: none on the canvas, or mobile browsers will helpfully scroll and pinch-zoom your game while the player tries to play it.
Move with delta time, and check the unit
const step = speed * dt; // dt in SECONDS
if (player.position.distanceTo(target) > step) {
player.position.addScaledVector(dir, step);
} else {
player.position.copy(target); // snap, or you oscillate forever
}
Two traps in four lines. THREE.Clock.getDelta() gives seconds; a raw requestAnimationFrame timestamp gives milliseconds, and passing the wrong one produces motion that is wrong by a factor of 1000 without erroring. And omitting the snap leaves the player jittering around the target forever, because it can never land exactly.
The part that makes it feel good
Everything above makes tap-to-move correct. What makes it feel deliberate is the acknowledgement: a small burst at the tap point, fired on pointerdown, before the character has moved at all.
That single effect does more for perceived responsiveness than any amount of movement tuning, because it answers the player's real question — did it hear me? — in the same frame as the input.
This tutorial goes end to end on the mobile-first version: renderer with a capped pixel ratio, scene and camera, the loop, pointer input, and a tap-burst effect wired into the same per-frame update.
What I'd tell someone starting today
Write the input layer yourself and get the mapping right, because it is small and everything else sits on top of it. Then borrow the feedback layer, because hand-rolling a particle system teaches you about object pooling rather than about your game.
Top comments (0)