Lesson 40 - Three.js Game Development
Three.js Keyboard, Pointer, and Mobile Controls from One Input Model
Good controls do not scatter event logic through the game. We translate every device into the same movement and action intent, then let simulation consume that intent once per frame.
1. Store keys, do not move in events
Keyboard handlers should update state only. Movement belongs in the simulation step, where delta time, collisions, and game state are available.
const keys = new Set();
window.addEventListener("keydown", (event) => {
if (event.target.matches("input, textarea, select")) return;
keys.add(event.code);
if (["Space", "ArrowUp", "ArrowDown"].includes(event.code)) event.preventDefault();
});
window.addEventListener("keyup", (event) => keys.delete(event.code));
const axis = (negative, positive) => Number(keys.has(positive)) - Number(keys.has(negative));
How this code works
Execution flow
keydown adds a physical key code to a Set, keyup removes it, and the axis helper converts two opposing keys into minus one, zero, or one. Text controls are excluded and selected browser-scroll keys are prevented only during gameplay input.
Key Three.js decisions
KeyboardEvent.code tracks physical layout positions suitable for WASD controls, while Set naturally handles held keys and repeated keydown events. Simulation reads this stable state once per frame instead of moving at the operating system's repeat rate.
Adapt it
Map multiple codes to named actions for remapping and accessibility, then combine keyboard state with gamepad or touch intent. Add a blur handler that clears the Set so releasing a key outside the window cannot leave movement stuck.
Watch out
Moving directly inside keydown makes speed depend on repeat settings and bypasses collision order. Calling preventDefault globally breaks typing, buttons, and comment forms, so always guard editable targets and only suppress keys owned by the active game.
2. Translate devices into intent
A small intent object gives keyboard, gamepad, joystick, and accessibility controls one contract. Normalize diagonals before applying speed.
const intent = { move: new THREE.Vector2(), jump: false, fire: false };
function readKeyboard() {
intent.move.set(axis("KeyA", "KeyD"), axis("KeyS", "KeyW"));
if (intent.move.lengthSq() > 1) intent.move.normalize();
intent.jump = keys.has("Space");
intent.fire = keys.has("KeyF");
}
How this code works
Execution flow
The reusable intent object stores a two-dimensional movement vector plus action booleans. readKeyboard fills it from axis values and normalizes diagonal input, after which game simulation consumes the same shape regardless of the original device.
Key Three.js decisions
THREE.Vector2 supplies length and normalization without involving world-space coordinates. Separating intent from movement lets collisions, speed, stamina, and camera-relative transformation run in one deterministic update path.
Adapt it
Add analog strength from a gamepad or touch stick without changing movePlayer. For vehicles, rename move axes to steer and throttle; for aiming games, maintain a second Vector2 for look intent and preserve dead-zone handling at the adapter layer.
Watch out
Without normalization, pressing two directions produces a vector length near 1.414 and makes diagonal movement faster. Reusing one object also means every device adapter must deliberately overwrite stale values each frame instead of leaving old actions active.
3. Use pointer capture for a joystick
Pointer capture keeps drag updates arriving even when a thumb leaves the joystick element. Clamp the vector to a circle and release it cleanly on cancel.
pad.addEventListener("pointerdown", (event) => {
pad.setPointerCapture(event.pointerId);
updateStick(event);
});
pad.addEventListener("pointermove", (event) => {
if (pad.hasPointerCapture(event.pointerId)) updateStick(event);
});
pad.addEventListener("pointerup", resetStick);
pad.addEventListener("pointercancel", resetStick);
function resetStick() { touchAxis.set(0, 0); }
How this code works
Execution flow
pointerdown captures the active pointer and initializes stick position. Subsequent pointermove events update only while that pointer remains captured, and both normal release and cancellation reset the shared touch axis to neutral.
Key Three.js decisions
Pointer Events unify mouse, pen, and touch, while setPointerCapture keeps a drag coherent beyond element boundaries. Checking hasPointerCapture prevents unrelated fingers or pointers from taking over the active joystick state.
Adapt it
Clamp displacement to the pad radius and divide by that radius to create normalized analog input. Add a second captured button for jump or boost, and position controls inside mobile safe areas without covering important game objects.
Watch out
Handling only pointerup can leave controls stuck when the browser cancels a gesture, rotates, or loses focus. Missing touch-action CSS may also let the page pan or zoom instead of delivering smooth pointer movement to the joystick.
4. Move relative to the camera
For third-person games, screen-up should usually mean camera-forward on the ground plane. Build forward and right vectors once, combine them with input, and normalize.
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, camera.up).normalize();
const movement = new THREE.Vector3()
.addScaledVector(forward, intent.move.y)
.addScaledVector(right, intent.move.x);
if (movement.lengthSq() > 1) movement.normalize();
How this code works
Execution flow
The camera's world direction becomes a forward vector, its vertical component is removed, and it is normalized on the ground plane. A perpendicular right vector is computed, both are weighted by input, and the final movement is normalized.
Key Three.js decisions
getWorldDirection respects the camera quaternion, crossVectors creates an orthogonal basis, and Vector3.addScaledVector avoids temporary scalar vectors. Flattening Y prevents looking upward from making the player fly in a ground-based game.
Adapt it
For flying controls, keep the vertical component or use camera.up as a third axis. For an isometric game, use fixed world-aligned forward and right vectors so movement does not change when the camera performs decorative rotations.
Watch out
Cross-product order controls whether right points correctly or is mirrored. If controls reverse after the camera looks nearly vertical, the flattened forward vector is approaching zero; constrain pitch or provide a fallback ground direction.
5. Real Supagames example
Production source: biggames/valley-racer/game.js
Play the published example: Valley Racer
Valley Racer maps keyboard and mobile steering into common vehicle controls. Peak Hopper follows the same principle for movement, jumping, aiming, and firing while guarding browser shortcuts and focus states.
The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.
6. Common mistakes
- Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
- Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
- Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.
7. Build checklist
- Serve ES modules through HTTP during development instead of opening the page through file://.
- Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
- Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
- Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.