Lesson 47 - Three.js Game Development
Three.js Enemy AI, Projectiles, Damage, and Combat Feedback
An enemy is interesting when it observes, decides, telegraphs, and gives the player a fair response window. We will implement that loop without spawning garbage on every shot or hit.
1. Give enemies explicit states
State transitions make behavior inspectable. An enemy patrols until it sees the player, chases until attack range, and waits through a telegraphed cooldown.
function updateEnemy(enemy, player, dt) {
const distance = enemy.position.distanceTo(player.position);
if (enemy.state === "patrol" && distance < 12) enemy.state = "chase";
if (enemy.state === "chase" && distance < 3.5) {
enemy.state = "attack";
enemy.timer = 0.55;
}
if (enemy.state === "attack") {
enemy.timer -= dt;
if (enemy.timer <= 0) fireAtPlayer(enemy, player);
}
}
How this code works
Execution flow
Each update measures player distance, transitions patrol to chase at detection range, transitions chase to attack at close range, starts a telegraph timer, and fires only after that timer expires. State and timer remain visible for debugging.
Key Three.js decisions
A finite-state structure prevents several incompatible behaviors from running simultaneously. distanceTo is acceptable for a small enemy count, while explicit timer state creates a fair anticipation window rather than instant contact damage.
Adapt it
Add line-of-sight before chase, recovery after attack, and retreat at low health. Different enemy classes can share transition helpers while supplying distinct ranges, movement speeds, telegraphs, and projectile factories.
Watch out
The shown attack state must transition back after firing in the full implementation or it will fire repeatedly once the timer stays below zero. If enemies oscillate at range boundaries, use hysteresis with different enter and exit distances.
2. Pool projectile meshes
Reuse projectiles rather than constructing geometry and materials mid-fight. A pool entry contains both visual and gameplay state.
const projectileGeometry = new THREE.SphereGeometry(0.12, 8, 6);
const projectileMaterial = new THREE.MeshBasicMaterial({ color: 0xffb347 });
const projectiles = Array.from({ length: 32 }, () => {
const mesh = new THREE.Mesh(projectileGeometry, projectileMaterial);
mesh.visible = false;
scene.add(mesh);
return { mesh, active: false, velocity: new THREE.Vector3(), life: 0 };
});
How this code works
Execution flow
The system creates geometry and material once, then builds a fixed array of hidden mesh-state pairs during loading. Firing later activates a free entry, sets its transform, velocity, and lifetime, and avoids constructing WebGL resources during combat.
Key Three.js decisions
Shared low-segment SphereGeometry keeps projectiles readable at small screen size, and MeshBasicMaterial remains bright without dynamic lights. Pool entries combine visual and gameplay fields so activation and retirement remain atomic.
Adapt it
Use InstancedMesh for hundreds of visually identical bullets, or separate pools by projectile material and collision radius. Size the pool from measured peak fire rate and gracefully skip or recycle oldest shots when exhausted.
Watch out
A pool that never deactivates entries eventually stops firing. Sharing one material means changing opacity or color on a single mesh affects every projectile, so store per-shot variation elsewhere or use a controlled set of material pools.
3. Update and retire projectiles
Activate the first free entry, move it with delta time, and deactivate it on hit or expiry. Keep collision radius slightly generous for readable arcade combat.
function updateProjectiles(dt, player) {
for (const shot of projectiles) {
if (!shot.active) continue;
shot.mesh.position.addScaledVector(shot.velocity, dt);
shot.life -= dt;
const hit = shot.mesh.position.distanceToSquared(player.position) < 0.65 * 0.65;
if (hit) damagePlayer(1);
if (hit || shot.life <= 0) {
shot.active = false;
shot.mesh.visible = false;
}
}
}
How this code works
Execution flow
The loop skips inactive entries, advances active meshes by velocity times delta, reduces lifetime, checks squared-radius contact with the player, applies damage once, and resets both gameplay state and visibility on hit or expiry.
Key Three.js decisions
addScaledVector performs allocation-free integration for constant velocity, and distanceToSquared matches the inexpensive spherical hitbox. Explicit active and visible flags keep the fixed pool reusable without array splicing during iteration.
Adapt it
Replace the point check with swept collision for very fast shots, add world-cover raycasts, and store owner or team masks to prevent friendly fire. Trail effects should read the projectile position but retire with the same pool entry.
Watch out
Fast projectiles can tunnel because this sample checks only the new endpoint. Calling damage before deactivation is safe only when damage cannot throw; otherwise use a finally-style retirement path so broken feedback does not leave immortal shots.
4. Make contact damage visible and fair
An invulnerability timer prevents one overlap from draining every life. Reuse a prepared overlay and knock the player away so the next decision is clear.
let invulnerableFor = 0;
function damagePlayer(amount, sourcePosition) {
if (invulnerableFor > 0) return;
playerState.health = Math.max(0, playerState.health - amount);
invulnerableFor = 0.8;
hitOverlay.visible = true;
const knockback = new THREE.Vector3().subVectors(player.position, sourcePosition).normalize();
playerVelocity.addScaledVector(knockback, 5);
}
function updateDamage(dt) {
invulnerableFor = Math.max(0, invulnerableFor - dt);
hitOverlay.visible = invulnerableFor > 0 && Math.floor(invulnerableFor * 12) % 2 === 0;
}
How this code works
Execution flow
damagePlayer first rejects hits during invulnerability, clamps health, starts a protection window, reveals feedback, and adds knockback away from the source. updateDamage reduces the timer and blinks a precreated overlay until protection ends.
Key Three.js decisions
An invulnerability window converts sustained overlap into one readable event. Vector3 subtraction derives the escape direction, and reusing one overlay avoids allocations or shader changes at the moment of impact.
Adapt it
Scale knockback by enemy type, pause briefly for strong attacks, and expose health loss through HTML HUD, sound, and controller vibration. On death, switch game state once and stop further damage processing before showing retry controls.
Watch out
If source and player positions are identical, normalization produces no useful escape direction, so provide a fallback. Visual minus-one text alone is not enough: health, animation, movement response, and eventual death must all reflect the same state change.
5. Real Supagames example
Production source: biggames/peak-hopper/src/main.js
Play the published example: Peak Hopper
Peak Hopper includes enemy contact, weapon firing, projectile updates, damage feedback, and pooled visual effects. The teaching version isolates those responsibilities into explicit state and reusable arrays.
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.