Gun game

This sketch is a full first-person shooter built with three.js for 3D rendering while p5.js supplies procedural gunshot sounds through p5.sound. You move through an arena with WASD, look around with the mouse via Pointer Lock, and fight off waves of AI enemies that chase and shoot back until either you or all the AIs are defeated.

🧪 Try This!

Experiment with the code by making these changes:

  1. Sprint through the arena — Increasing playerSpeed makes the player move noticeably faster across the map with every W/A/S/D press.
  2. Unleash a horde — Raising numAIs spawns far more enemies at the start of every round, turning the fight into a chaotic horde battle.
  3. Recolor the player's bullets — The bulletMaterial color determines what color tracer the player's shots leave as they fly across the arena.
Prefer the full editor? Open it there →

📖 About This Sketch

This project turns a p5.js sketch into a full 3D first-person shooter by handing all of the rendering over to three.js while p5.js only supplies audio - notice how setup() calls noCanvas() so p5 never draws its own canvas. You get mouse-look and WASD movement through THREE.PointerLockControls, raycasting for both wall collision and bullet hits, a small state machine of AI enemies that chase and shoot the player, and layered noise synthesis from p5.sound for gunshots. It is an excellent case study in mixing two very different libraries: p5.js for lifecycle hooks and audio, three.js for everything visual.

The code is organized around one big initGame() function that builds the scene, camera, renderer, controls, environment, and enemies, followed by an animateGame() loop (driven by requestAnimationFrame, not p5's draw()) that updates player movement, AI behavior, and bullets every frame. Helper functions handle narrower jobs - createEnvironment() builds the map, initAIs() spawns enemies safely, shoot()/aiShoot() spawn bullets, and takeDamage()/updateUI() manage health bars. Studying it will teach you how raycasting can substitute for a physics engine, how to structure a real-time game loop, and how to keep global game state in sync with an HTML UI.

⚙️ How It Works

  1. When the page loads, preload() configures two noise-based sound generators (one for the player's gun, one for AI guns) and setup() calls noCanvas() then initGame(), which builds the three.js scene, camera, renderer, PointerLockControls, the player's capsule mesh, the starting gun, the arena floor and walls, and five AI enemies placed at safe random spawn points.
  2. Clicking the screen locks the mouse pointer and starts animateGame(), a requestAnimationFrame loop that runs independently of p5's draw() - each frame it reads keyboard state to build a movement vector, uses several raycasts around the player's capsule to stop the player from walking through walls, and applies simple gravity to keep the player glued to the floor.
  3. Every active AI enemy moves toward the player's position each frame, rotates to face them, and fires a bullet on its own timer; the player fires by left-clicking once their gun's cooldown timer has elapsed.
  4. All bullets (player and AI) are stored in one array and updated each frame: their position advances along a stored direction vector, and distance checks against AI capsules and the player capsule detect hits, while a short raycast forward detects wall or obstacle collisions so bullets disappear on impact.
  5. takeDamage() and updateUI() keep playerHealth and each AI's health in sync with two on-screen health bars, and endGame() triggers when the player's health hits zero or every AI is defeated, showing a game-over message and automatically calling restartGame() after five seconds to reset health, reposition everyone, pick a new random map, and respawn a fresh set of AIs.

🎓 Concepts You'll Learn

Mixing p5.js with an external 3D library (three.js)First-person camera controls with PointerLockControlsRaycasting for collision detection and hit-scan style checksReal-time game loop with requestAnimationFrame and delta timeObject arrays for managing bullets and AI enemiesProcedural audio synthesis with p5.sound noise and envelopesGame state machines (active, game over, restart)

📝 Code Breakdown

preload()

preload() is a special p5.js function that runs once before setup(), traditionally used to load assets. Here it's repurposed to build synthesized sound effects instead of loading audio files, which avoids extra network requests.

function preload() {
  // Player Gun Sound
  playerGunSound = new p5.Noise('white');
  playerGunEnv = new p5.Envelope();
  playerGunEnv.setADSR(0.005, 0.1, 0.5, 0.1); // Attack, Decay, Sustain, Release
  playerGunSound.amp(playerGunEnv);
  playerGunSound.freq(300); // Base frequency
  playerGunSound.start();
  playerGunSound.stop(); // Start and immediately stop to initialize

  // AI Gun Sound
  aiGunSound = new p5.Noise('pink'); // Slightly different noise for AI
  aiGunEnv = new p5.Envelope();
  aiGunEnv.setADSR(0.01, 0.15, 0.4, 0.2);
  aiGunSound.amp(aiGunEnv);
  aiGunSound.freq(200);
  aiGunSound.start();
  aiGunSound.stop(); // Start and immediately stop to initialize
}
Line-by-line explanation (6 lines)
playerGunSound = new p5.Noise('white');
Creates a white-noise generator, which sounds like a harsh hiss - great raw material for a gunshot.
playerGunEnv = new p5.Envelope();
An Envelope shapes a sound's volume over time (attack, decay, sustain, release) instead of playing at a constant volume.
playerGunEnv.setADSR(0.005, 0.1, 0.5, 0.1); // Attack, Decay, Sustain, Release
Configures a very fast attack (0.005s) so the noise snaps on instantly like a gunshot, then decays quickly.
playerGunSound.amp(playerGunEnv);
Tells the noise generator to use the envelope to control its volume instead of playing at a fixed level.
playerGunSound.start(); playerGunSound.stop(); // Start and immediately stop to initialize
Starting then immediately stopping 'warms up' the audio node so the very first real shot doesn't have a startup glitch.
aiGunSound = new p5.Noise('pink'); // Slightly different noise for AI
Pink noise has a different tonal balance than white noise, giving AI gunshots a distinct sound from the player's.

setup()

setup() runs once when the sketch starts, same as in any p5.js sketch, but here it's used purely as an entry point to bootstrap a three.js application rather than to draw with p5's own API.

function setup() {
  noCanvas(); // CRITICAL: Prevent p5.js from creating a canvas
  initGame(); // Call our three.js initialization function
}
Line-by-line explanation (2 lines)
noCanvas(); // CRITICAL: Prevent p5.js from creating a canvas
By default p5.js creates its own 2D canvas - this cancels that so three.js's WebGL canvas is the only one on the page.
initGame(); // Call our three.js initialization function
Hands control over to the custom three.js setup function, which builds the scene, camera, renderer, and game objects.

draw()

Normally draw() is where all your animation logic lives, but when you bring in a separate rendering engine like three.js, you can let draw() sit empty and drive everything from that engine's own loop instead.

function draw() {
  // We use three.js's requestAnimationFrame for rendering,
  // but p5.js draw() can be used for other UI or game logic
  // if needed. For this three.js game, animateGame() handles
  // the main loop.
  // The p5.js warning about a black canvas can be ignored
  // as three.js creates its own canvas.
}
Line-by-line explanation (1 lines)
function draw() {}
p5.js still calls draw() 60 times a second automatically, but it is intentionally left empty because all rendering is handled by three.js's own animateGame() loop via requestAnimationFrame instead.

initGame()

This function is the one-time setup phase of the game - equivalent to p5's setup() but written in raw three.js. Everything that needs to exist before the game loop starts (scene, camera, renderer, controls, player, environment, AIs, lights, listeners) is built here exactly once.

function initGame() {
  // === Scene Setup ===
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x87ceeb); // Sky blue background

  // === Camera Setup ===
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
  camera.position.set(0, playerGroundY, 0); // Player starts at ground level (Y = playerGroundY)

  // === Renderer Setup ===
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  document.body.appendChild(renderer.domElement);

  // === PointerLockControls for First-Person Perspective ===
  controls = new THREE.PointerLockControls(camera, renderer.domElement);
  infoScreen = document.getElementById('info');
  gameOverScreen = document.getElementById('gameOverScreen');
  gameOverText = document.getElementById('gameOverText');
  playerHealthBar = document.getElementById('playerHealth');
  aiHealthBar = document.getElementById('aiHealth');
  document.getElementById('currentGunName').textContent = gunConfigurations[currentGunIndex].name;

  // Lock pointer on click, show info screen when locked
  renderer.domElement.addEventListener('click', function() {
    if (!gameActive) {
      if (gameOverScreen.style.display === 'flex') {
        // If game over screen is visible and clicked, clear any pending restart
        if (gameRestartTimer) {
          clearTimeout(gameRestartTimer);
          gameRestartTimer = null;
        }
        restartGame(); // Restart immediately on click if game over
      } else {
        controls.lock();
        infoScreen.style.display = 'none';
        gameActive = true;
        animateGame(); // Start the three.js animation loop here
        userStartAudio(); // Start p5.sound audio context on user gesture
      }
    }
  });

  // Unlock pointer, show info screen when unlocked
  controls.addEventListener('lock', function() {
    infoScreen.style.display = 'none';
    gameActive = true;
  });
  controls.addEventListener('unlock', function() {
    infoScreen.style.display = 'block';
    gameActive = false;
  });

  scene.add(controls.getObject()); // Add camera (as part of controls) to the scene

  // === Player Model (simple capsule for now) ===
  const playerGeometry = new THREE.CapsuleGeometry(playerCapsuleRadius, playerCapsuleHeight, 4, 8);
  const playerMaterial = new THREE.MeshStandardMaterial({ color: 0x00ff00, transparent: true, opacity: 0.5 }); // Semi-transparent for visibility
  player = new THREE.Mesh(playerGeometry, playerMaterial);
  player.position.set(0, -playerCapsuleHeight / 2, 0); // Position relative to camera in controls object
  controls.getObject().add(player); // Player mesh is a child of the controls object

  // === Initialize Gun ===
  switchGun(currentGunIndex);

  // === Environment (Floor and Walls) ===
  currentMapId = 'square'; // Start with the square map
  createEnvironment(currentMapId); // CRITICAL: Call createEnvironment BEFORE initAIs

  // === Initialize AIs ===
  initAIs(); // Now mapConfig will be defined

  // === Lights ===
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
  scene.add(ambientLight);

  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
  directionalLight.position.set(5, 10, 5);
  scene.add(directionalLight);

  // === Raycaster for Shooting ===
  raycaster = new THREE.Raycaster();

  // === Event Listeners for Movement and Shooting ===
  document.addEventListener('keydown', onKeyDown);
  document.addEventListener('keyup', onKeyUp);
  document.addEventListener('mousedown', onMouseDown); // For shooting

  // === Initialize UI ===
  updateUI();
  gameOverScreen.style.display = 'none';
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Click-to-Lock / Restart Logic if (!gameActive) { if (gameOverScreen.style.display === 'flex') { ... } else { ... } }

Decides whether a click should restart a finished game or start/lock the pointer for a fresh game

scene = new THREE.Scene();
Creates the three.js container that will hold every 3D object - meshes, lights, and the camera.
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
Sets up a perspective camera with a 75-degree field of view, matching the window's aspect ratio, seeing objects between 0.1 and 1000 units away.
renderer = new THREE.WebGLRenderer({ antialias: true });
Creates the WebGL renderer that actually draws pixels to the screen using the GPU, with anti-aliasing for smoother edges.
controls = new THREE.PointerLockControls(camera, renderer.domElement);
Wraps the camera in PointerLockControls, which hides the mouse cursor and lets small mouse movements rotate the camera - the classic FPS look controls.
renderer.domElement.addEventListener('click', function() { ... });
Listens for a click on the canvas: if the game isn't active it either restarts (if game-over is showing) or locks the pointer and starts animateGame().
scene.add(controls.getObject());
The controls object acts as the camera's parent - adding it to the scene is what actually makes the camera (and anything attached to it) visible and positioned in 3D space.
player = new THREE.Mesh(playerGeometry, playerMaterial); player.position.set(0, -playerCapsuleHeight / 2, 0); controls.getObject().add(player);
Builds a semi-transparent capsule mesh to represent the player's body and attaches it beneath the camera so it moves together with the player.
switchGun(currentGunIndex);
Equips the starting gun by calling the gun-switching helper with the default index (0, the Pistol).
createEnvironment(currentMapId); // CRITICAL: Call createEnvironment BEFORE initAIs
Builds the floor, walls, and obstacles first, because initAIs() needs environmentMeshes to already exist to check that AI spawn points are on solid ground.
initAIs(); // Now mapConfig will be defined
Spawns the starting set of AI enemies now that the map geometry exists to test spawn positions against.
document.addEventListener('keydown', onKeyDown); document.addEventListener('keyup', onKeyUp); document.addEventListener('mousedown', onMouseDown);
Wires up the browser's native keyboard and mouse events to this game's input handler functions.

createEnvironment()

This function demonstrates data-driven level design: instead of hard-coding every wall and obstacle, the mapConfigurations object stores plain data, and createEnvironment() turns that data into real three.js meshes. This pattern makes it easy to add new maps just by adding new config objects.

🔬 This loop reads the obstacles array from mapConfigurations.obstacles and builds one box per entry. What happens if you add a new object like { type: 'box', size: [4,4,4], position: [0,2,0] } to that array in the 'obstacles' map config?

  for (const obstacleConfig of mapConfig.obstacles) {
    let obstacleMesh;
    if (obstacleConfig.type === 'box') {
      obstacleMesh = new THREE.Mesh(new THREE.BoxGeometry(...obstacleConfig.size), obstacleMaterial);
    }
    if (obstacleMesh) {
      obstacleMesh.position.set(...obstacleConfig.position);
      scene.add(obstacleMesh);
      environmentMeshes.push(obstacleMesh);
    }
  }
function createEnvironment(mapId) {
  // Clear existing environment meshes
  for (const mesh of environmentMeshes) {
    scene.remove(mesh);
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) mesh.material.dispose();
  }
  environmentMeshes = [];

  const mapConfig = mapConfigurations[mapId];
  if (!mapConfig) {
    console.warn(`Map ID '${mapId}' not found. Using 'square' map.`);
    mapId = 'square';
    mapConfig = mapConfigurations[mapId];
  }

  const floorMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, side: THREE.DoubleSide });
  const wallMaterial = new THREE.MeshStandardMaterial({ color: 0x555555 });
  const obstacleMaterial = new THREE.MeshStandardMaterial({ color: 0x8b4513 }); // Brown for obstacles

  // Floor
  const floorGeometry = new THREE.PlaneGeometry(mapConfig.floorSize, mapConfig.floorSize);
  floor = new THREE.Mesh(floorGeometry, floorMaterial); // Store reference to floor
  floor.rotation.x = -Math.PI / 2; // Rotate to be horizontal
  floor.position.y = 0;
  scene.add(floor);
  environmentMeshes.push(floor);

  // Walls
  const arenaSize = mapConfig.arenaSize;
  const wallHeight = mapConfig.wallHeight;

  // Wall 1 (Back)
  const wall1 = new THREE.Mesh(new THREE.PlaneGeometry(arenaSize * 2, wallHeight), wallMaterial);
  wall1.position.set(0, wallHeight / 2, -arenaSize);
  scene.add(wall1);
  environmentMeshes.push(wall1);

  // Wall 2 (Front)
  const wall2 = new THREE.Mesh(new THREE.PlaneGeometry(arenaSize * 2, wallHeight), wallMaterial);
  wall2.position.set(0, wallHeight / 2, arenaSize);
  wall2.rotation.y = Math.PI;
  scene.add(wall2);
  environmentMeshes.push(wall2);

  // Wall 3 (Left)
  const wall3 = new THREE.Mesh(new THREE.PlaneGeometry(arenaSize * 2, wallHeight), wallMaterial);
  wall3.position.set(-arenaSize, wallHeight / 2, 0);
  wall3.rotation.y = Math.PI / 2;
  scene.add(wall3);
  environmentMeshes.push(wall3);

  // Wall 4 (Right)
  const wall4 = new THREE.Mesh(new THREE.PlaneGeometry(arenaSize * 2, wallHeight), wallMaterial);
  wall4.position.set(arenaSize, wallHeight / 2, 0);
  wall4.rotation.y = -Math.PI / 2;
  scene.add(wall4);
  environmentMeshes.push(wall4);

  // Obstacles
  for (const obstacleConfig of mapConfig.obstacles) {
    let obstacleMesh;
    if (obstacleConfig.type === 'box') {
      obstacleMesh = new THREE.Mesh(new THREE.BoxGeometry(...obstacleConfig.size), obstacleMaterial);
    }
    if (obstacleMesh) {
      obstacleMesh.position.set(...obstacleConfig.position);
      scene.add(obstacleMesh);
      environmentMeshes.push(obstacleMesh);
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Clear Previous Environment for (const mesh of environmentMeshes) { scene.remove(mesh); ... }

Removes and disposes every mesh from the previous map before building the new one, preventing memory leaks and duplicate geometry

conditional Missing Map Fallback if (!mapConfig) { ... mapId = 'square'; mapConfig = mapConfigurations[mapId]; }

Falls back to the 'square' map if an invalid map ID is passed in

for-loop Build Obstacles for (const obstacleConfig of mapConfig.obstacles) { ... }

Loops through the current map's obstacle list and creates a positioned box mesh for each one

for (const mesh of environmentMeshes) { scene.remove(mesh); ... }
Cleans up everything from the previous map by removing meshes from the scene and freeing their GPU geometry/material memory.
const mapConfig = mapConfigurations[mapId];
Looks up the settings object (floor size, wall height, obstacle list) for the requested map name.
floor.rotation.x = -Math.PI / 2; // Rotate to be horizontal
A PlaneGeometry is vertical by default, so it's rotated -90 degrees to lie flat as the ground.
const wall1 = new THREE.Mesh(new THREE.PlaneGeometry(arenaSize * 2, wallHeight), wallMaterial); wall1.position.set(0, wallHeight / 2, -arenaSize);
Builds one wall as a flat plane sized to the arena and positions it at the back edge, half its height above the ground so its base sits on the floor.
obstacleMesh = new THREE.Mesh(new THREE.BoxGeometry(...obstacleConfig.size), obstacleMaterial);
Spreads the [width, height, depth] array from the config into BoxGeometry's arguments to build a box of the exact size defined in mapConfigurations.

initAIs()

This function shows a common game-dev pattern called 'rejection sampling': instead of calculating a perfect spawn point mathematically, it just keeps guessing random points and throwing away the bad ones until it finds one that satisfies all the rules (or gives up after 100 tries).

function initAIs() {
  const numAIs = 5; // Number of AIs
  const mapConfig = mapConfigurations[currentMapId]; // mapConfig is now defined!
  const arenaSize = mapConfig.arenaSize; // Max random spawn distance from center
  const minPlayerDistance = 10; // Minimum distance from player start
  const minAIDistance = 5;      // Minimum distance between AIs

  const aiGeometry = new THREE.CapsuleGeometry(0.5, 1.5, 4, 8);
  const aiMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 });

  ais = []; // Clear existing AIs
  totalAIHealth = 0;
  totalAIMaxHealth = 0;

  for (let i = 0; i < numAIs; i++) {
    let aiMesh;
    let startX, startZ;
    let attempts = 0;

    do {
      startX = (Math.random() - 0.5) * arenaSize * 1.8; // Slightly smaller area for AIs
      startZ = (Math.random() - 0.5) * arenaSize * 1.8;
      aiMesh = new THREE.Mesh(aiGeometry, aiMaterial.clone()); // Clone material for potential future unique colors
      aiMesh.position.set(startX, 0.75, startZ);

      // Check distance from player start
      const playerStartPos = new THREE.Vector3(0, 0.75, 0);
      if (aiMesh.position.distanceTo(playerStartPos) < minPlayerDistance) {
        attempts++;
        continue;
      }

      // Check distance from existing AIs
      let tooCloseToOtherAI = false;
      for (const existingAI of ais) {
        if (aiMesh.position.distanceTo(existingAI.mesh.position) < minAIDistance) {
          tooCloseToOtherAI = true;
          break;
        }
      }
      if (tooCloseToOtherAI) {
        attempts++;
        continue;
      }

      // Simple check to ensure AI is not inside an obstacle immediately
      const tempRaycaster = new THREE.Raycaster(aiMesh.position, new THREE.Vector3(0, -1, 0));
      tempRaycaster.far = 1; // Check only for ground directly below
      const intersects = tempRaycaster.intersectObjects(environmentMeshes);
      let onGround = false;
      for(const intersect of intersects) {
        if (intersect.object === floor) { // Make sure it's the actual floor, not an obstacle
          onGround = true;
          break;
        }
      }
      if (!onGround) {
        attempts++;
        continue;
      }


      break; // Position is valid
    } while (attempts < 100); // Prevent infinite loop if no space

    const aiObject = {
      mesh: aiMesh,
      health: 100,
      maxHealth: 100,
      shootTimer: Math.random() * aiShootDelay, // Stagger initial shoot timers
      shootDelay: aiShootDelay,
      speed: aiSpeed,
    };
    ais.push(aiObject);
    scene.add(aiMesh);

    totalAIHealth += aiObject.health;
    totalAIMaxHealth += aiObject.maxHealth;
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Spawn Each AI for (let i = 0; i < numAIs; i++) { ... }

Creates numAIs enemy objects, one per iteration, each with its own mesh, health, and shoot timer

while-loop Find a Safe Spawn Point do { ... } while (attempts < 100);

Repeatedly picks random coordinates until one is far enough from the player, far enough from other AIs, and actually on the floor

for-loop Check Distance To Other AIs for (const existingAI of ais) { if (aiMesh.position.distanceTo(existingAI.mesh.position) < minAIDistance) { ... } }

Prevents two AIs from spawning on top of each other

const numAIs = 5; // Number of AIs
Sets how many enemies will be created this round - the for-loop below runs this many times.
startX = (Math.random() - 0.5) * arenaSize * 1.8;
Generates a random coordinate roughly centered on 0, scaled by the arena size so AIs can spawn anywhere in most of the arena but not right against the walls.
if (aiMesh.position.distanceTo(playerStartPos) < minPlayerDistance) { attempts++; continue; }
distanceTo() calculates 3D distance; if the candidate spot is too close to where the player starts, the attempt is rejected and the do-while loop tries again.
const tempRaycaster = new THREE.Raycaster(aiMesh.position, new THREE.Vector3(0, -1, 0)); tempRaycaster.far = 1;
Casts a short ray straight down from the candidate position to check what's directly beneath it, limited to 1 unit away.
} while (attempts < 100); // Prevent infinite loop if no space
Caps the retry loop at 100 attempts so a cramped map can't cause an infinite loop if no valid spot can be found.
shootTimer: Math.random() * aiShootDelay, // Stagger initial shoot timers
Gives each AI a random starting point on its shoot timer so all 5 enemies don't fire in perfect unison the moment the game starts.

animateGame()

This is the heart of the game - a per-frame update function that handles input, movement, physics-like collision via raycasting, AI behavior, and bullet simulation all in one place. Studying its structure (locked-controls check, then AI loop, then bullet loop, then win/loss check, then render) is a great template for writing your own real-time game loops.

🔬 This is the hit-detection radius for bullets vs AIs (bullet radius 0.2 + AI radius 0.5 = 0.7 units). What happens to gameplay if you make that combined radius much bigger, like 2.0, so shots barely need to be aimed?

      if (bullet.position.distanceTo(currentAI.mesh.position) < (0.2 + 0.5)) { // 0.2 is bullet radius, 0.5 is AI capsule radius
        scene.remove(bullet);
        if (bullet.geometry) bullet.geometry.dispose();
        if (bullet.material) bullet.material.dispose();
        bullets.splice(i, 1);
        takeDamage(currentAI, currentGun.damage); // AI takes damage from current gun
        removedBullet = true;
        break; // Bullet hit an AI, so it's gone
      }
function animateGame() {
  requestAnimationFrame(animateGame); // Loop using requestAnimationFrame for smoother three.js rendering

  const delta = clock.getDelta(); // Time since last frame

  if (controls.isLocked === true) {
    // === Player Movement with Collision ===
    const desiredMoveDirection = new THREE.Vector3();
    if (moveForward) desiredMoveDirection.z -= 1;
    if (moveBackward) desiredMoveDirection.z += 1;
    if (moveLeft) desiredMoveDirection.x -= 1;
    if (moveRight) desiredMoveDirection.x += 1;
    desiredMoveDirection.normalize(); // Ensure constant speed

    const cameraWorldDirection = new THREE.Vector3();
    camera.getWorldDirection(cameraWorldDirection);
    cameraWorldDirection.y = 0; // Ignore vertical component for horizontal movement
    cameraWorldDirection.normalize();

    const cameraRight = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), cameraWorldDirection).normalize();

    let worldMoveVector = new THREE.Vector3();
    worldMoveVector.addScaledVector(cameraWorldDirection, -desiredMoveDirection.z);
    worldMoveVector.addScaledVector(cameraRight, -desiredMoveDirection.x);
    worldMoveVector.y = 0; // Prevent vertical movement from WASD
    worldMoveVector.normalize();

    let proposedMovement = worldMoveVector.clone().multiplyScalar(playerSpeed * delta);

    const collisionOffset = 0.1; // Small buffer for collision
    const playerRadius = playerCapsuleRadius + collisionOffset;

    const rayOrigins = [
      controls.getObject().position.clone(), // Center
      controls.getObject().position.clone().add(new THREE.Vector3(0, playerCapsuleHeight / 2 - playerCapsuleRadius, 0)), // Top of capsule
      controls.getObject().position.clone().add(new THREE.Vector3(0, -(playerCapsuleHeight / 2 - playerCapsuleRadius), 0)), // Bottom of capsule
      controls.getObject().position.clone().add(new THREE.Vector3(playerRadius * 0.7, 0, playerRadius * 0.7)),
      controls.getObject().position.clone().add(new THREE.Vector3(-playerRadius * 0.7, 0, playerRadius * 0.7)),
      controls.getObject().position.clone().add(new THREE.Vector3(playerRadius * 0.7, 0, -playerRadius * 0.7)),
      controls.getObject().position.clone().add(new THREE.Vector3(-playerRadius * 0.7, 0, -playerRadius * 0.7)),
    ];

    const tempRaycaster = new THREE.Raycaster();
    tempRaycaster.far = playerRadius + proposedMovement.length(); // Max distance to check

    for (const origin of rayOrigins) {
      tempRaycaster.set(origin, proposedMovement.clone().normalize());
      const intersects = tempRaycaster.intersectObjects(environmentMeshes);

      for (const intersect of intersects) {
        if (intersect.distance < playerRadius) {
          const normal = intersect.face.normal.clone().transformDirection(intersect.object.matrixWorld);
          const dot = proposedMovement.dot(normal);
          if (dot < 0) { // Moving towards the wall
            proposedMovement.addScaledVector(normal, -dot);
          }
        }
      }
    }

    controls.getObject().position.add(proposedMovement);

    velocity.y -= 9.8 * 100.0 * delta; // Gravity

    controls.getObject().position.y += (velocity.y * delta);

    if (controls.getObject().position.y < playerGroundY) {
      velocity.y = 0;
      controls.getObject().position.y = playerGroundY;
    }

    playerShootTimer += delta;

    for (let j = ais.length - 1; j >= 0; j--) {
      const currentAI = ais[j];
      if (currentAI.health <= 0) {
        scene.remove(currentAI.mesh);
        if (currentAI.mesh.geometry) currentAI.mesh.geometry.dispose();
        if (currentAI.mesh.material) currentAI.mesh.material.dispose();
        ais.splice(j, 1);
        continue;
      }

      const playerPos = controls.getObject().position.clone();
      const aiPos = currentAI.mesh.position.clone();

      const moveDir = new THREE.Vector3().subVectors(playerPos, aiPos).normalize();
      currentAI.mesh.position.addScaledVector(moveDir, currentAI.speed * delta);

      if (currentAI.mesh.position.y < 0.75) {
        currentAI.mesh.position.y = 0.75;
      }

      currentAI.mesh.lookAt(playerPos);

      currentAI.shootTimer += delta;
      if (currentAI.shootTimer >= currentAI.shootDelay) {
        currentAI.shootTimer = 0;
        aiShoot(currentAI);
      }
    }
  }

  for (let i = bullets.length - 1; i >= 0; i--) {
    const bullet = bullets[i];
    bullet.position.addScaledVector(bullet.direction, bullet.speed * delta);

    let removedBullet = false;

    for (let j = ais.length - 1; j >= 0; j--) {
      const currentAI = ais[j];
      if (currentAI.health <= 0) continue;

      if (bullet.position.distanceTo(currentAI.mesh.position) < (0.2 + 0.5)) { // 0.2 is bullet radius, 0.5 is AI capsule radius
        scene.remove(bullet);
        if (bullet.geometry) bullet.geometry.dispose();
        if (bullet.material) bullet.material.dispose();
        bullets.splice(i, 1);
        takeDamage(currentAI, currentGun.damage); // AI takes damage from current gun
        removedBullet = true;
        break;
      }
    }
    if (removedBullet) continue;

    if (player && bullet.position.distanceTo(controls.getObject().position) < (0.2 + playerCapsuleRadius)) {
      scene.remove(bullet);
      if (bullet.geometry) bullet.geometry.dispose();
      if (bullet.material) bullet.material.dispose();
      bullets.splice(i, 1);
      takeDamage(player, 10); // Player takes 10 damage from AI bullet
      continue;
    }

    const bulletRaycaster = new THREE.Raycaster(bullet.position, bullet.direction);
    bulletRaycaster.far = bullet.speed * delta * 1.5;
    const intersects = bulletRaycaster.intersectObjects(environmentMeshes);

    for (const intersect of intersects) {
      let isAIMesh = false;
      for (const ai of ais) {
        if (intersect.object === ai.mesh) {
          isAIMesh = true;
          break;
        }
      }
      if (isAIMesh || intersect.object === player) continue;

      scene.remove(bullet);
      if (bullet.geometry) bullet.geometry.dispose();
      if (bullet.material) bullet.material.dispose();
      bullets.splice(i, 1);
      removedBullet = true;
      break;
    }
    if (removedBullet) continue;

    if (bullet.position.distanceTo(camera.position) > 100) {
      scene.remove(bullet);
      if (bullet.geometry) bullet.geometry.dispose();
      if (bullet.material) bullet.material.dispose();
      bullets.splice(i, 1);
    }
  }

  if (playerHealth <= 0) {
    endGame("GAME OVER - You were defeated! Restarting in 5 seconds...");
  } else if (ais.length === 0) {
    endGame("YOU WIN! Restarting in 5 seconds...");
  }

  renderer.render(scene, camera);
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

conditional Only Move When Pointer Locked if (controls.isLocked === true) { ... }

Skips all movement, AI, and shooting logic while the mouse pointer is unlocked (e.g. paused on the info screen)

for-loop Player Wall Collision Rays for (const origin of rayOrigins) { ... }

Casts several rays around the player's capsule to detect and cancel movement into walls or obstacles

for-loop Update Every AI for (let j = ais.length - 1; j >= 0; j--) { ... }

Moves each living AI toward the player, removes dead ones, and triggers their shooting timer

for-loop Update Every Bullet for (let i = bullets.length - 1; i >= 0; i--) { ... }

Advances each bullet, checks it against AIs, the player, walls, and max range, removing it when it hits or expires

conditional Check Win/Loss Conditions if (playerHealth <= 0) { ... } else if (ais.length === 0) { ... }

Ends the game with a win or loss message once the player's health hits zero or every AI has been defeated

requestAnimationFrame(animateGame); // Loop using requestAnimationFrame for smoother three.js rendering
Schedules this same function to run again next frame, creating the game loop - this is separate from p5's own draw() loop.
const delta = clock.getDelta(); // Time since last frame
Gets the exact time in seconds since the last frame, used to make movement speed independent of frame rate (frame-rate-independent motion).
desiredMoveDirection.normalize(); // Ensure constant speed
Without this, pressing two keys at once (like W+D) would produce a longer diagonal vector, making diagonal movement faster than straight movement - normalizing fixes that.
let proposedMovement = worldMoveVector.clone().multiplyScalar(playerSpeed * delta);
Converts the direction into an actual distance to travel this frame by multiplying by speed and delta time.
tempRaycaster.set(origin, proposedMovement.clone().normalize());
Points a ray from each capsule-edge origin in the direction the player wants to move, to check if a wall lies in the way.
if (dot < 0) { // Moving towards the wall proposedMovement.addScaledVector(normal, -dot); }
Uses a dot product to check if movement is heading into the wall, then cancels out just the component of movement pointing into the wall - this lets the player slide along walls instead of stopping dead.
velocity.y -= 9.8 * 100.0 * delta; // Gravity
Applies a downward acceleration to simulate gravity - the large multiplier compensates for how the scene's units are scaled.
if (controls.getObject().position.y < playerGroundY) { velocity.y = 0; controls.getObject().position.y = playerGroundY; }
Simple ground collision: once the player falls to the ground height, stop the fall and snap exactly to that height.
const moveDir = new THREE.Vector3().subVectors(playerPos, aiPos).normalize(); currentAI.mesh.position.addScaledVector(moveDir, currentAI.speed * delta);
Computes the direction from the AI to the player, then moves the AI a small step in that direction - this simple 'seek' behavior makes every AI chase the player directly.
if (bullet.position.distanceTo(currentAI.mesh.position) < (0.2 + 0.5)) {
A cheap distance-based hit test: if the bullet's center is closer to the AI than the sum of their two radii, they're considered to be touching.
if (playerHealth <= 0) { endGame("GAME OVER - You were defeated! Restarting in 5 seconds..."); } else if (ais.length === 0) { endGame("YOU WIN! Restarting in 5 seconds..."); }
Checked every frame - as soon as either win or loss condition becomes true, the game-over sequence starts.
renderer.render(scene, camera);
The final step of every frame: tells three.js to actually draw the current state of the scene from the camera's viewpoint onto the canvas.

onMouseDown()

This function listens for native browser mousedown events rather than p5's mousePressed(), since the game bypasses p5's canvas entirely. It demonstrates a simple cooldown/fire-rate pattern using an accumulating timer variable.

function onMouseDown(event) {
  if (!gameActive) return;

  // 1 = Left click, check player shoot timer
  if (event.button === 0 && playerShootTimer >= currentGun.shootDelay) {
    shoot();
    playerShootTimer = 0; // Reset timer after shooting
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Fire Rate Gate if (event.button === 0 && playerShootTimer >= currentGun.shootDelay) { ... }

Only fires when the left mouse button is pressed AND enough time has passed since the last shot, enforcing each gun's fire rate

if (!gameActive) return;
Ignores clicks entirely while the game isn't active, such as when the info or game-over screen is showing.
if (event.button === 0 && playerShootTimer >= currentGun.shootDelay) {
event.button === 0 means the left mouse button; comparing the accumulated playerShootTimer against the current gun's shootDelay enforces its fire rate.
playerShootTimer = 0; // Reset timer after shooting
Resets the cooldown timer so the player must wait shootDelay seconds again (counted up in animateGame()) before firing another shot.

shoot()

shoot() shows how to spawn a temporary game object (the bullet), give it custom movement data, and hand it off to a central array that animateGame() will manage every frame - a common 'object pool' style pattern in games.

function shoot() {
  // Get camera's forward direction
  const direction = new THREE.Vector3();
  camera.getWorldDirection(direction);

  // Create bullet
  const bulletGeometry = new THREE.SphereGeometry(0.1, 8, 8);
  const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // Yellow bullet
  const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);

  // Position bullet slightly in front of the gun
  const gunWorldPos = new THREE.Vector3();
  gun.getWorldPosition(gunWorldPos);
  bullet.position.copy(gunWorldPos).addScaledVector(direction, 0.5); // Start slightly in front of gun

  bullet.direction = direction; // Store direction for movement
  bullet.speed = currentGun.bulletSpeed; // Bullet speed from current gun

  bullets.push(bullet);
  scene.add(bullet);

  // Play player gun sound
  playerGunEnv.play(playerGunSound);
  playerGunSound.freq(random(currentGun.freqMin || 250, currentGun.freqMax || 350)); // Vary frequency slightly for realism
}
Line-by-line explanation (6 lines)
camera.getWorldDirection(direction);
Reads the exact direction the camera is currently facing in world space - this becomes the bullet's travel direction.
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);
Creates a small yellow sphere mesh to visually represent the bullet as it flies.
gun.getWorldPosition(gunWorldPos); bullet.position.copy(gunWorldPos).addScaledVector(direction, 0.5);
Starts the bullet at the gun model's world position, then nudges it half a unit forward so it doesn't spawn overlapping the gun mesh.
bullet.direction = direction; bullet.speed = currentGun.bulletSpeed;
Attaches custom properties directly onto the mesh object - three.js meshes are plain JS objects, so you can freely store extra game data on them like this.
bullets.push(bullet); scene.add(bullet);
Adds the bullet to the tracking array (so animateGame() will update it every frame) and to the scene (so it's actually rendered).
playerGunSound.freq(random(currentGun.freqMin || 250, currentGun.freqMax || 350)); // Vary frequency slightly for realism
Randomizes the noise generator's pitch slightly on every shot so gunfire doesn't sound like an identical repeating loop.

aiShoot()

aiShoot() mirrors shoot() closely but computes its aim direction toward a moving target (the player) instead of using the camera's facing direction, showing how the same 'spawn a bullet' pattern can be reused for both player and enemy weapons.

function aiShoot(shootingAI) { // Now accepts the specific AI object that is shooting
  if (shootingAI.health <= 0 || playerHealth <= 0) return; // Don't shoot if dead

  // Get direction from AI to player
  const direction = new THREE.Vector3().subVectors(controls.getObject().position, shootingAI.mesh.position).normalize();

  // Create bullet
  const bulletGeometry = new THREE.SphereGeometry(0.1, 8, 8);
  const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }); // Red bullet
  const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial);

  // Position bullet slightly in front of AI
  bullet.position.copy(shootingAI.mesh.position).addScaledVector(direction, 0.5); // Start slightly in front of AI
  bullet.position.y += 0.5; // Aim slightly higher (adjust as needed for AI height)

  bullet.direction = direction; // Store direction for movement
  bullet.speed = 40; // AI bullet speed

  bullets.push(bullet);
  scene.add(bullet);

  // Play AI gun sound
  aiGunEnv.play(aiGunSound);
  aiGunSound.freq(random(150, 250)); // Vary AI frequency
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Skip Shooting If Dead if (shootingAI.health <= 0 || playerHealth <= 0) return;

Prevents a dead AI or a defeated player from still firing bullets

if (shootingAI.health <= 0 || playerHealth <= 0) return;
An early exit (guard clause) that stops the function immediately if either combatant is already dead, avoiding wasted work or unfair extra shots.
const direction = new THREE.Vector3().subVectors(controls.getObject().position, shootingAI.mesh.position).normalize();
Subtracting the AI's position from the player's position gives a vector pointing from the AI to the player; normalize() makes it length 1 so it's a pure direction.
bullet.position.y += 0.5; // Aim slightly higher (adjust as needed for AI height)
Nudges the bullet's spawn height up slightly so shots appear to come from around the AI's torso/gun height rather than its feet.
bullet.speed = 40; // AI bullet speed
Unlike the player's bullet speed (which comes from the equipped gun), AI bullets always travel at this fixed speed.

switchGun()

This function demonstrates three.js's scene graph parenting: attaching an object to the camera (camera.add(gun)) automatically keeps it locked to the view, which is exactly how first-person weapon models are typically implemented.

function switchGun(index) {
  if (gun) {
    camera.remove(gun); // Remove old gun model
    if (gun.geometry) gun.geometry.dispose();
    if (gun.material) gun.material.dispose();
  }
  currentGunIndex = index;
  currentGun = gunConfigurations[currentGunIndex];

  // Clone the model to ensure each gun has its own mesh instance
  gun = currentGun.model.clone();
  gun.position.set(0.4, -0.2, -0.6); // Position relative to camera
  camera.add(gun); // Gun is a child of the camera

  document.getElementById('currentGunName').textContent = currentGun.name;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Clean Up Previous Gun if (gun) { camera.remove(gun); ... }

Removes and disposes the previously equipped gun model before attaching a new one, avoiding stacked duplicate gun meshes

if (gun) { camera.remove(gun); ... }
Only tries to remove an old gun if one actually exists yet (it won't on the very first call during initGame()).
currentGun = gunConfigurations[currentGunIndex];
Looks up the full stats object (damage, fire rate, bullet speed, model) for the newly selected gun from the gunConfigurations array.
gun = currentGun.model.clone(); // Clone the model to ensure each gun has its own mesh instance
Clones the shared template mesh from gunConfigurations rather than reusing the same object, since three.js meshes can only exist in one place in the scene at a time.
gun.position.set(0.4, -0.2, -0.6); // Position relative to camera
Positions the gun model relative to its parent - since it's added to the camera, this places it in the lower-right of the view, like a classic FPS gun.
camera.add(gun); // Gun is a child of the camera
Making the gun a child of the camera means it automatically moves and rotates with the camera every frame, with no extra code needed.

takeDamage()

This function centralizes all damage logic in one place so both bullets and any future damage source (like explosions or melee) can call the same function, keeping health-clamping and UI updates consistent.

function takeDamage(target, amount) {
  if (target === player) {
    playerHealth -= amount;
    playerHealth = Math.max(0, playerHealth); // Don't go below 0
    console.log(`Player Health: ${playerHealth}`);
  } else if (target && target.mesh) { // Check if it's an AI object from the ais array
    target.health -= amount;
    target.health = Math.max(0, target.health);
    console.log(`AI Health (${target.mesh.uuid}): ${target.health}`); // Use mesh UUID for unique AI ID

    // Recalculate total AI health
    totalAIHealth = 0;
    for (const ai of ais) {
      totalAIHealth += ai.health;
    }
  }
  updateUI();
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Player vs AI Damage if (target === player) { ... } else if (target && target.mesh) { ... }

Applies damage differently depending on whether the target is the player object or an AI object from the ais array

for-loop Recompute Total AI Health for (const ai of ais) { totalAIHealth += ai.health; }

Sums every remaining AI's health so the shared AI health bar reflects the whole group, not just one enemy

playerHealth = Math.max(0, playerHealth); // Don't go below 0
Math.max(0, ...) clamps the value so health can never display as negative, even if damage exceeds remaining health.
} else if (target && target.mesh) { // Check if it's an AI object from the ais array
AI objects are plain objects with a .mesh property, so checking for that property distinguishes an AI target from the player mesh.
totalAIHealth = 0; for (const ai of ais) { totalAIHealth += ai.health; }
Recalculates the combined health of all surviving AIs from scratch, which the shared AI health bar in the HTML UI displays.
updateUI();
Whenever health changes for anyone, immediately refreshes the on-screen health bars to match.

updateUI()

This function is a simple bridge between the three.js/game-logic world and the plain HTML/CSS UI overlay - it reads JS numbers and writes them out as CSS width percentages, which the .health-fill CSS transition then animates smoothly.

function updateUI() {
  playerHealthBar.style.width = (playerHealth / playerMaxHealth) * 100 + '%';

  // Update AI health bar based on total health of all active AIs
  totalAIHealth = 0;
  for (const ai of ais) {
    totalAIHealth += ai.health;
  }
  if (totalAIMaxHealth > 0) {
    aiHealthBar.style.width = (totalAIHealth / totalAIMaxHealth) * 100 + '%';
  } else {
    aiHealthBar.style.width = '0%'; // All AIs defeated
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Sum Remaining AI Health for (const ai of ais) { totalAIHealth += ai.health; }

Adds up the health of every AI still in the array to get a combined total

conditional Avoid Divide-By-Zero if (totalAIMaxHealth > 0) { ... } else { aiHealthBar.style.width = '0%'; }

Falls back to an empty bar instead of computing NaN when totalAIMaxHealth is 0

playerHealthBar.style.width = (playerHealth / playerMaxHealth) * 100 + '%';
Converts current health into a percentage and sets that as a CSS width, visually shrinking the green health bar div as health drops.
if (totalAIMaxHealth > 0) { aiHealthBar.style.width = (totalAIHealth / totalAIMaxHealth) * 100 + '%'; } else { aiHealthBar.style.width = '0%'; }
Guards against dividing by zero (which would produce NaN and break the CSS) in the edge case where totalAIMaxHealth hasn't been set yet.

endGame()

endGame() is the transition point between the 'playing' and 'game over' states - it turns off input handling, shows UI feedback, and schedules the next state transition (restartGame) automatically using setTimeout.

function endGame(message) {
  gameActive = false;
  controls.unlock(); // Unlock pointer
  gameOverText.textContent = message;
  gameOverScreen.style.display = 'flex'; // Show game over screen
  playerGunSound.stop(); // Stop sounds when game ends
  aiGunSound.stop();

  // Set a timeout to automatically restart the game
  gameRestartTimer = setTimeout(restartGame, 5000); // Restart after 5 seconds
}
Line-by-line explanation (4 lines)
gameActive = false;
Flips the global flag so animateGame()'s movement/AI logic, onMouseDown(), and the click handler all treat the game as paused.
controls.unlock(); // Unlock pointer
Releases the mouse pointer lock so the player's cursor becomes visible and usable again.
gameOverScreen.style.display = 'flex'; // Show game over screen
Reveals the full-screen game-over overlay defined in the HTML/CSS, which was hidden with display:none during play.
gameRestartTimer = setTimeout(restartGame, 5000); // Restart after 5 seconds
Schedules restartGame() to run automatically after a 5 second delay, storing the timer ID so it can be cancelled if the player clicks to restart early.

restartGame()

restartGame() is the 'reset everything' function - it's important that it clears every array (ais, bullets) and disposes of old three.js geometries/materials to avoid memory leaks, since the game can restart many times in one browser session.

function restartGame() {
  // Clear any existing restart timer
  if (gameRestartTimer) {
    clearTimeout(gameRestartTimer);
    gameRestartTimer = null;
  }

  gameActive = false;
  gameOverScreen.style.display = 'none';
  infoScreen.style.display = 'block';

  // Reset player
  playerHealth = playerMaxHealth;
  playerShootTimer = 0;
  controls.getObject().position.set(0, playerGroundY, 0); // Reset player position
  velocity.set(0, 0, 0);
  // Reset camera rotation to face a default direction (e.g., along -Z axis)
  camera.rotation.set(0, 0, 0);
  camera.lookAt(new THREE.Vector3(0, playerGroundY, -1)); // Make camera look forward

  // Clear existing AIs and re-initialize
  for (const ai of ais) {
    scene.remove(ai.mesh);
    if (ai.mesh.geometry) ai.mesh.geometry.dispose();
    if (ai.mesh.material) ai.mesh.material.dispose();
  }
  ais = []; // Clear array explicitly

  // Clear existing bullets
  for (const bullet of bullets) {
    scene.remove(bullet);
    if (bullet.geometry) bullet.geometry.dispose();
    if (bullet.material) bullet.material.dispose();
  }
  bullets = []; // Clear array explicitly

  // Choose a random map for the next round
  const mapIds = Object.keys(mapConfigurations);
  currentMapId = mapIds[Math.floor(Math.random() * mapIds.length)];
  createEnvironment(currentMapId); // Re-create environment for the new map

  initAIs(); // Re-initialize all AIs with full health and new positions

  updateUI();
  document.getElementById('currentGunName').textContent = currentGun.name; // Ensure gun name is updated
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Remove All AIs for (const ai of ais) { scene.remove(ai.mesh); ... }

Removes and disposes every leftover AI mesh from the scene before spawning a fresh set

for-loop Remove All Bullets for (const bullet of bullets) { scene.remove(bullet); ... }

Clears any bullets still flying when the round ended so they don't carry over into the new round

calculation Pick a Random Map currentMapId = mapIds[Math.floor(Math.random() * mapIds.length)];

Randomly selects between the available map configurations so each round can look different

playerHealth = playerMaxHealth;
Fully heals the player back to their maximum health for the new round.
controls.getObject().position.set(0, playerGroundY, 0); // Reset player position
Teleports the player back to the center of the arena at standing height.
camera.lookAt(new THREE.Vector3(0, playerGroundY, -1)); // Make camera look forward
Resets the camera's look direction to face along the -Z axis, undoing whatever direction the player was looking when the round ended.
const mapIds = Object.keys(mapConfigurations); currentMapId = mapIds[Math.floor(Math.random() * mapIds.length)];
Gets the names of all defined maps ('square', 'obstacles') and picks one at random using Math.random() scaled to the array length.
initAIs(); // Re-initialize all AIs with full health and new positions
Rebuilds the entire enemy roster from scratch with full health at fresh, safely-checked spawn points.

onKeyDown()

This function shows the standard pattern for keyboard-driven games: keydown sets boolean flags to 'true', a matching keyup handler sets them back to 'false', and the actual movement math lives in the game loop rather than in the event handler itself.

function onKeyDown(event) {
  switch (event.code) {
    case 'KeyW':
      moveForward = true;
      break;
    case 'KeyA':
      moveLeft = true;
      break;
    case 'KeyS':
      moveBackward = true;
      break;
    case 'KeyD':
      moveRight = true;
      break;
    case 'Space':
      // Jumping not fully implemented with capsule collision yet
      // if (canJump === true) velocity.y += 350;
      // canJump = false;
      break;
    case 'Digit1':
      switchGun(0);
      break;
    case 'Digit2':
      switchGun(1);
      break;
    case 'Digit3':
      switchGun(2);
      break;
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

switch-case Route Key Presses switch (event.code) { case 'KeyW': ... }

Maps each physical key to a movement flag or a gun switch based on which key was just pressed

switch (event.code) {
event.code identifies the physical key pressed (e.g. 'KeyW') regardless of keyboard layout, which is more reliable than event.key for movement controls.
case 'KeyW': moveForward = true; break;
Sets the moveForward flag to true while W is held down; animateGame() reads this flag every frame to build the movement vector.
case 'Space': // Jumping not fully implemented with capsule collision yet break;
This case is a placeholder - the commented-out code shows jumping was planned but never finished, so pressing Space currently does nothing.
case 'Digit1': switchGun(0); break;
Pressing the '1' key immediately calls switchGun(0), equipping the Pistol (index 0 in gunConfigurations).

onKeyUp()

This is the mirror image of onKeyDown() - together they implement continuous, held-key movement rather than one-shot movement per key press, which is essential for smooth FPS-style controls.

function onKeyUp(event) {
  switch (event.code) {
    case 'KeyW':
      moveForward = false;
      break;
    case 'KeyA':
      moveLeft = false;
      break;
    case 'KeyS':
      moveBackward = false;
      break;
    case 'KeyD':
      moveRight = false;
      break;
  }
}
Line-by-line explanation (1 lines)

🔧 Subcomponents:

switch-case Clear Movement Flags switch (event.code) { case 'KeyW': moveForward = false; break; ... }

Turns off the corresponding movement flag the moment a movement key is released

case 'KeyW': moveForward = false; break;
When W is released, moveForward is set back to false, so the player stops accelerating forward on the very next frame.

windowResized()

p5.js automatically calls windowResized() whenever the browser window changes size, so even though this game uses three.js for rendering, it piggybacks on p5's lifecycle hook to keep the 3D view properly sized.

function windowResized() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}
Line-by-line explanation (3 lines)
camera.aspect = window.innerWidth / window.innerHeight;
Updates the camera's aspect ratio to match the new window dimensions so the 3D scene doesn't look stretched or squashed.
camera.updateProjectionMatrix();
Three.js caches the camera's projection matrix for performance, so this call is required after changing properties like aspect ratio for the change to actually take effect.
renderer.setSize(window.innerWidth, window.innerHeight);
Resizes the actual WebGL canvas (and its internal rendering resolution) to match the new browser window size.

📦 Key Variables

scene object

The three.js Scene that holds every mesh, light, and the camera - the root container for everything visible

scene = new THREE.Scene();
camera object

The perspective camera representing the player's first-person viewpoint

camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
renderer object

The WebGL renderer that draws the scene to the canvas each frame

renderer = new THREE.WebGLRenderer({ antialias: true });
player object

The mesh representing the player's own body (a semi-transparent capsule), attached to the controls object

player = new THREE.Mesh(playerGeometry, playerMaterial);
ais array

Holds every active AI enemy object (mesh, health, shootTimer, speed) currently in the round

let ais = [];
bullets array

Holds every bullet mesh currently flying, whether fired by the player or an AI

let bullets = [];
controls object

The PointerLockControls instance that turns mouse movement into camera rotation and manages the pointer lock state

controls = new THREE.PointerLockControls(camera, renderer.domElement);
raycaster object

A general-purpose three.js Raycaster instance used for shooting/pathfinding checks

raycaster = new THREE.Raycaster();
clock object

A THREE.Clock used to measure delta time between frames for frame-rate-independent movement

let clock = new THREE.Clock();
gunConfigurations array

Defines the stats (damage, fire rate, bullet speed, 3D model) for each of the three available guns

const gunConfigurations = [{ name: "Pistol", damage: 10, ... }];
currentGunIndex number

The index into gunConfigurations of the gun the player currently has equipped

let currentGunIndex = 0;
currentGun object

A direct reference to the currently equipped gun's config object, for quick access to its stats

let currentGun;
gun object

The actual cloned three.js mesh of the equipped gun, attached as a child of the camera

let gun;
moveForward / moveBackward / moveLeft / moveRight boolean

Flags toggled by keydown/keyup that tell animateGame() which WASD directions are currently being held

let moveForward = false;
velocity object

A THREE.Vector3 used to track the player's vertical (gravity) velocity between frames

let velocity = new THREE.Vector3();
playerHealth number

The player's current hit points, decreased by AI bullets and checked for the game-over condition

let playerHealth = 100;
playerMaxHealth number

The player's maximum possible health, used to calculate the health bar percentage

let playerMaxHealth = 100;
playerShootTimer number

Accumulates elapsed time since the player's last shot, compared against the gun's shootDelay to gate firing

let playerShootTimer = 0;
playerSpeed number

How many meters per second the player moves when a movement key is held

const playerSpeed = 10;
playerCapsuleRadius / playerCapsuleHeight number

Define the size of the player's collision capsule, used both for the visible mesh and wall-collision math

const playerCapsuleRadius = 0.5;
playerGroundY number

The Y height the player's camera should rest at when standing on the floor

const playerGroundY = playerCapsuleHeight / 2;
aiSpeed number

The default movement speed assigned to every newly spawned AI

let aiSpeed = 2;
aiShootDelay number

The default seconds between shots for every newly spawned AI

let aiShootDelay = 2.5;
totalAIHealth / totalAIMaxHealth number

Combined current and maximum health across all living AIs, used to drive the shared AI health bar

let totalAIHealth = 0;
gameActive boolean

Tracks whether the game is currently being played (pointer locked) versus paused/game-over

let gameActive = false;
gameRestartTimer object

Stores the setTimeout ID for the automatic restart so it can be cancelled if the player clicks to restart early

let gameRestartTimer = null;
environmentMeshes array

Holds every static mesh (floor, walls, obstacles) used for collision raycasting against the player and bullets

let environmentMeshes = [];
mapConfigurations object

Stores the data (floor size, wall height, arena size, obstacle list) for each available map layout

const mapConfigurations = { square: {...}, obstacles: {...} };
currentMapId string

The key identifying which map layout is currently active

let currentMapId;
floor object

A direct reference to the floor mesh, used to distinguish 'standing on ground' from 'standing on an obstacle' during spawn checks

let floor;
playerGunSound / aiGunSound object

p5.sound Noise generators used as the raw sound source for player and AI gunshots respectively

playerGunSound = new p5.Noise('white');
playerGunEnv / aiGunEnv object

p5.sound Envelopes that shape the volume of the noise generators into a short percussive gunshot sound

playerGunEnv = new p5.Envelope();

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG createEnvironment()

mapConfig is declared with const ('const mapConfig = mapConfigurations[mapId];') but then reassigned inside the fallback block ('mapConfig = mapConfigurations[mapId];') when an invalid map ID is passed - reassigning a const variable throws a TypeError and will crash the function.

💡 Declare it with 'let mapConfig = mapConfigurations[mapId];' instead of 'const' so it can be safely reassigned in the fallback branch.

PERFORMANCE animateGame()

A brand new THREE.Raycaster is created every single frame for player wall-collision checks, and a new Raycaster is also created for every bullet every frame, which generates a lot of garbage-collected objects and can cause frame-rate stutter with many bullets on screen.

💡 Create one or two reusable Raycaster instances outside the loop (e.g. globally) and just call .set() on them each frame instead of allocating new ones.

BUG shoot()

shoot() reads 'currentGun.freqMin || 250' and 'currentGun.freqMax || 350', but none of the objects in gunConfigurations actually define freqMin or freqMax, so every gun always falls back to the same 250-350 range regardless of which gun is equipped.

💡 Add freqMin/freqMax properties to each entry in gunConfigurations so the Pistol, Assault Rifle, and Shotgun each have a distinct gunshot pitch.

FEATURE onKeyDown() / animateGame()

The 'Space' key case and the velocity.y gravity system suggest jumping was planned, but it's entirely commented out, so Space currently does nothing.

💡 Track a simple 'isGrounded' boolean (true when position.y equals playerGroundY) and, on Space, add a positive velocity.y impulse only when isGrounded is true, to add a working jump.

🔄 Code Flow

Code flow showing preload, setup, draw, initgame, createenvironment, initais, animategame, onmousedown, shoot, aishoot, switchgun, takedamage, updateui, endgame, restartgame, onkeydown, onkeyup, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> animategame[animategame] animategame --> locked-controls-check[locked-controls-check] locked-controls-check -->|If locked| ai-update-loop[ai-update-loop] locked-controls-check -->|If unlocked| draw ai-update-loop --> ai-distance-check[ai-distance-check] ai-distance-check -->|Check Distance| ai-spawn-loop[ai-spawn-loop] ai-spawn-loop --> safe-position-retry[safe-position-retry] safe-position-retry -->|Find Safe Position| ai-update-loop ai-update-loop --> bullet-update-loop[bullet-update-loop] bullet-update-loop --> wall-collision-rays[wall-collision-rays] wall-collision-rays -->|Check Collisions| bullet-update-loop bullet-update-loop --> game-over-check[game-over-check] game-over-check -->|Win/Loss Check| endgame[endgame] endgame --> restartgame[restartgame] restartgame --> clear-ais[clear-ais] clear-ais --> clear-bullets[clear-bullets] clear-bullets --> random-map-pick[random-map-pick] random-map-pick --> createenvironment[createenvironment] createenvironment --> clear-old-meshes[clear-old-meshes] clear-old-meshes --> map-fallback-check[map-fallback-check] map-fallback-check --> obstacle-builder[obstacle-builder] obstacle-builder --> initais[initais] initais --> animategame click setup href "#fn-setup" click draw href "#fn-draw" click animategame href "#fn-animategame" click locked-controls-check href "#sub-locked-controls-check" click ai-update-loop href "#sub-ai-update-loop" click ai-distance-check href "#sub-ai-distance-check" click ai-spawn-loop href "#sub-ai-spawn-loop" click safe-position-retry href "#sub-safe-position-retry" click bullet-update-loop href "#sub-bullet-update-loop" click wall-collision-rays href "#sub-wall-collision-rays" click game-over-check href "#sub-game-over-check" click endgame href "#fn-endgame" click restartgame href "#fn-restartgame" click clear-ais href "#sub-clear-ais" click clear-bullets href "#sub-clear-bullets" click random-map-pick href "#sub-random-map-pick" click createenvironment href "#fn-createenvironment" click clear-old-meshes href "#sub-clear-old-meshes" click map-fallback-check href "#sub-map-fallback-check" click obstacle-builder href "#sub-obstacle-builder"

❓ Frequently Asked Questions

What does this p5.js gun game create visually?

This p5.js sketch creates a 3D environment featuring a player character equipped with various guns, AI opponents, and a health system. The scene is rendered using three.js, showcasing different gun models and player interactions in a dynamic setting.

How can users interact with the gun game sketch?

Users can navigate the game using keyboard inputs to control the player's movement and shooting actions. The gameplay involves shooting at AI opponents, with the player managing health and switching between different guns.

What creative coding technique does this gun game demonstrate?

This sketch demonstrates the use of 3D modeling and physics simulation in a game environment using three.js. It incorporates concepts such as raycasting for shooting mechanics and AI pathfinding to create interactive gameplay.

How can I recreate a similar gun game effect in p5.js?

To recreate a similar effect in p5.js, you can start by using the p5.js WebGL renderer for 3D graphics. Implement basic object manipulation, keyboard interactions for movement, and create a shooting mechanic by detecting mouse clicks to simulate bullet firing.

Preview

Gun game - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Gun game - Code flow showing preload, setup, draw, initgame, createenvironment, initais, animategame, onmousedown, shoot, aishoot, switchgun, takedamage, updateui, endgame, restartgame, onkeydown, onkeyup, windowresized
Code Flow Diagram