DRIFT STARS (FINAL RELEASE)

Drift Stars is a full browser-based 3D arcade driving game built entirely in p5.js WEBGL mode. You drift a customizable car around a procedurally scattered cityscape of glowing cone obstacles, leave skidmarks and smoke trails, get chased by an escalating wanted system with five tiers of cop vehicles, and can hop out to explore on foot in first-person, all wrapped in a menu/shop/customizer flow built with plain HTML overlays.

🧪 Try This!

Experiment with the code by making these changes:

  1. Flood the map with obstacles — Raising the obstacle spawn count turns the sparse drift course into a dense, tricky slalom of glowing cones.
  2. Make cars turn on a dime — Increasing turnSpeed lets every car (player, NPC, and cops alike) whip around corners far more sharply.
  3. Switch to a dusk sky — The sky color is just a background() call - swap the RGB values for an instant sunset atmosphere.
Prefer the full editor? Open it there →

📖 About This Sketch

Drift Stars turns p5.js's WEBGL renderer into a driving arcade game: a custom Car class handles drift physics with slip-based traction, a Tornado class chases you in the special Copnado mode, and a five-tier cop-spawning system escalates the chase the longer you drift. The car itself is built entirely from primitive shapes - box(), cylinder(), and cone() - stacked in push()/pop() blocks to model spoilers, roll cages, and light bars that change as you buy armor and engine upgrades. Camera work is a highlight: the game lerps a chase camera behind the car based on speed, while an on-foot mode switches to a mouse-look first-person camera using pointer lock.

The code is organized around a big gameState machine (HOME, PLAYING, ON_FOOT, CUTSCENE_BUSTED, and more) that draw() branches on every frame, plus helper systems for skidmarks, smoke particles, NPC traffic, and a synthesized engine/music soundtrack built from raw p5.Oscillator objects. By studying it you'll learn how to blend 3D primitive shapes into a readable vehicle model, how to fake drift physics with vector lerping, how to drive a whole menu/shop/cutscene UI from vanilla DOM elements alongside a p5 canvas, and how to keep an ever-growing open world feeling infinite by recycling obstacles around the player.

⚙️ How It Works

  1. On load, setup() creates a full-window WEBGL canvas, builds the SoundManager, wires up every menu button and mobile touch control, and calls initGameWorld('NORMAL') to spawn the player's car, 60 random glowing obstacles, and 5 NPC cars.
  2. Every frame, draw() checks the current gameState and branches: menu/cutscene states just render a spinning showroom camera, while PLAYING reads WASD/arrow input, updates the Car object's drift physics, and smoothly lerps a chase camera behind it.
  3. As you drift (sliding sideways at speed), the Car class spawns skidmark planes and smoke particles behind it and earns you cash, while triggering the wanted system the first time you drift or crash.
  4. processWantedEscalation() ramps up a 1-5 star wanted level over time, spawning tougher cop types (SHERIFF, SWAT, MILITARY, SPECIAL_FORCES) that chase your position using simple angle-seeking AI in updateCops().
  5. Colliding with a cop, NPC, or obstacle calls handleCrash() or checkObstacleCollisions(), which apply bounce velocity and damage; when health hits zero, triggerGameOver() plays a scripted CUTSCENE_BUSTED sequence with a cop actor walking over to arrest you.
  6. Pressing E toggles between driving (PLAYING) and a first-person ON_FOOT mode with pointer-lock mouse look, and the in-game shop lets you spend drift cash on engine, armor, new cars, or a full RGB+slider custom builder that reskins your car live.

🎓 Concepts You'll Learn

WEBGL 3D primitives (box, cylinder, cone)Finite state machine for game screensVector-based drift physics with lerpCamera control (chase cam and pointer-lock FPS cam)Procedural/infinite world recyclingObject pooling for particles (smoke, skidmarks)Synthesized audio with p5.Oscillator/EnvelopeDOM/UI integration alongside a p5 canvas

📝 Code Breakdown

setup()

setup() runs once and is where you configure the renderer (WEBGL here), build long-lived objects like the SoundManager, and hook up all the HTML UI before the game loop starts.

function setup() {
  createCanvas(windowWidth, windowHeight, WEBGL);
  perspective(PI / 1.8, width / height, 10, 50000);
  
  sfx = new SoundManager(); 
  
  setupMenuInteractions();
  setupMobileControls();
  
  ['c-red', 'c-green', 'c-blue', 'c-eng', 'c-arm'].forEach(id => {
    let el = document.getElementById(id);
    if (el) el.addEventListener('input', updatePreviewCar);
  });

  initGameWorld('NORMAL');
  updateShopUI();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Custom Builder Slider Binding ['c-red', 'c-green', 'c-blue', 'c-eng', 'c-arm'].forEach(id => {

Attaches a live 'input' listener to every color/stat slider so the preview car updates as you drag them

createCanvas(windowWidth, windowHeight, WEBGL)
Creates a full-window 3D canvas using the WEBGL renderer, which unlocks box(), cylinder(), camera(), and lighting functions
perspective(PI / 1.8, width / height, 10, 50000)
Sets the field-of-view angle and the near/far clipping distances so distant obstacles up to 50,000 units away still render
sfx = new SoundManager();
Builds the audio engine object that manages engine, skid, crash, and music oscillators
setupMenuInteractions();
Wires up every menu, shop, and admin button's onclick handler
setupMobileControls();
Attaches touch/mouse handlers to the on-screen D-pad buttons for mobile play
initGameWorld('NORMAL');
Builds the player's car, obstacles, and NPCs so the game world exists even before the player presses Play
updateShopUI();
Refreshes the shop button labels to reflect starting cash and upgrade levels

initGameWorld()

initGameWorld() is the game's 'reset' function - it rebuilds every entity array from scratch, which is why it can be safely called again whenever a new mode is chosen from the menu.

🔬 This loop spawns obstacles in a ring between radius 500 and 4000. What happens if you change the radius range to random(0, 500) so everything clusters near the start?

  for (let i = 0; i < 60; i++) {
    let angle = random(TWO_PI);
    let radius = random(500, 4000);
    obstacles.push({ x: cos(angle) * radius, z: sin(angle) * radius, color: color(255, random(100, 200), 0) });
  }
function initGameWorld(mode) {
  gameMode = mode;
  
  car = new Car(0, 0, color(playerStats.color));
  car.isPlayer = true;
  
  // Copnado TIV specific overrides
  if (gameMode === 'COPNADO') {
    car.maxSpeed = 40;
    car.accel = 0.8;
    car.customStats = { engineLevel: 15, armorTier: 10 }; 
    car.color = color(20);
    tornado = new Tornado(0, -4000);
  } else {
    car.maxSpeed = playerStats.maxSpeed;
    car.accel = playerStats.accel;
    tornado = null;
  }
  
  // Co-op overrides
  if (gameMode === 'COOP') {
    car2 = new Car(150, 0, color(30, 30, 220));
    car2.isPlayer = true;
    car2.maxSpeed = playerStats.maxSpeed;
    car2.accel = playerStats.accel;
  } else {
    car2 = null;
  }
  
  camPos = createVector(0, -300);
  playerPos = createVector(0, -30, 0);
  
  obstacles = []; skidmarks = []; smokeParticles = []; npcs = []; cops = [];
  wantedStars = 0; chaseFrames = 0;
  
  updateWantedUI(); updateHealthUI(100); updateCashUI();
  
  document.getElementById('wanted-alert').style.display = 'none';
  document.getElementById('crosshair').style.display = 'none';
  document.getElementById('btn-skip-cutscene').style.display = 'none';
  
  for (let i = 0; i < 60; i++) {
    let angle = random(TWO_PI);
    let radius = random(500, 4000);
    obstacles.push({ x: cos(angle) * radius, z: sin(angle) * radius, color: color(255, random(100, 200), 0) });
  }
  for (let i = 0; i < 5; i++) spawnNPC();
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Copnado Mode Overrides if (gameMode === 'COPNADO') {

Boosts the player's car stats and spawns the chasing Tornado entity for the special mode

conditional Co-Op Setup if (gameMode === 'COOP') {

Creates a second player-controlled car for 2-player mode

for-loop Obstacle Field Generator for (let i = 0; i < 60; i++) {

Scatters 60 glowing cone obstacles randomly around the origin in a ring between radius 500-4000

car = new Car(0, 0, color(playerStats.color));
Creates the player's car using whatever color they last saved in playerStats
car.customStats = { engineLevel: 15, armorTier: 10 };
In Copnado mode the car uses a hardcoded max-tier stat object instead of the player's saved progress
obstacles = []; skidmarks = []; smokeParticles = []; npcs = []; cops = [];
Resets every array-based game system so a fresh game world starts empty
obstacles.push({ x: cos(angle) * radius, z: sin(angle) * radius, color: color(255, random(100, 200), 0) });
Adds one obstacle object with a random position on a ring and a random orange-ish glow color
for (let i = 0; i < 5; i++) spawnNPC();
Populates the world with 5 wandering traffic cars for atmosphere and crash hazards

draw()

draw() is the heart of any p5.js sketch, running ~60 times per second. Here it doubles as a state-machine dispatcher, choosing entirely different camera and update logic depending on gameState before doing the shared lighting and rendering work at the bottom.

🔬 The 0.08 here controls how quickly the camera 'catches up' to the car. What happens if you raise it to 0.5? What if you drop it to 0.01?

    camPos.x = lerp(camPos.x, desiredCamPos.x, 0.08);
    camPos.y = lerp(camPos.y, desiredCamPos.y, 0.08); 
function draw() {
  if (gameState === 'ADMIN_LOGIN' || gameState === 'ADMIN_PANEL') {
    background(15, 0, 15); return;
  }

  // --- CUTSCENES ---
  if (gameState === 'CUSTOMIZER') {
    background(10); ambientLight(60); spotLight(255, 255, 255, 0, -400, 0, 0, 1, 0, PI/3, 50);
    let t = millis(); camera(cos(t * 0.001) * 200, -100, sin(t * 0.001) * 200, 0, -20, 0, 0, 1, 0);
    push(); fill(20); noStroke(); rotateX(HALF_PI); plane(2000, 2000); pop();
    if (previewCar) previewCar.display();
    return; 
  }

  if (gameState === 'CUTSCENE_BUY_CAR') {
    background(5); ambientLight(40); spotLight(255, 255, 255, 0, -400, 0, 0, 1, 0, PI/3, 50);
    let t = millis() - cutsceneTimer;
    camera(cos(t * 0.0015) * 250, -120, sin(t * 0.0015) * 250, 0, -20, 0, 0, 1, 0);
    push(); fill(20); noStroke(); rotateX(HALF_PI); plane(2000, 2000); pop();
    cutsceneData.car.display();
    if (t > 4000) skipCutscene();
    return;
  }

  // Game Weather Rendering
  if (gameMode === 'COPNADO') { background(30, 40, 50); if (random() > 0.98) background(200); } // Lightning flashes
  else background(135, 206, 235); // Normal sky
  
  if (gameState === 'PLAYING') {
    // P1 Controls
    let gas1 = 0, steer1 = 0;
    if (keyIsDown(87) || touchInput.up) gas1 = 1;
    if (keyIsDown(83) || touchInput.down) gas1 = -1;
    if (keyIsDown(65) || touchInput.left) steer1 = -1;
    if (keyIsDown(68) || touchInput.right) steer1 = 1;
    if (gameMode !== 'COOP') {
      if (keyIsDown(UP_ARROW)) gas1 = 1; if (keyIsDown(DOWN_ARROW)) gas1 = -1;
      if (keyIsDown(LEFT_ARROW)) steer1 = -1; if (keyIsDown(RIGHT_ARROW)) steer1 = 1;
    }
    car.update(gas1, steer1);

    // P2 Controls
    if (gameMode === 'COOP' && car2) {
      let gas2 = 0, steer2 = 0;
      if (keyIsDown(UP_ARROW)) gas2 = 1; if (keyIsDown(DOWN_ARROW)) gas2 = -1;
      if (keyIsDown(LEFT_ARROW)) steer2 = -1; if (keyIsDown(RIGHT_ARROW)) steer2 = 1;
      car2.update(gas2, steer2);
    }
    
    // Dynamic Camera tracking one or both players
    let centerPos = car.pos.copy();
    let maxSpd = car.vel.mag();
    let baseCamDist = 250;
    
    if (gameMode === 'COOP' && car2) {
      centerPos.add(car2.pos).mult(0.5);
      baseCamDist = max(250, dist(car.pos.x, car.pos.y, car2.pos.x, car2.pos.y) + 150);
      maxSpd = max(maxSpd, car2.vel.mag());
    }
    
    let forward = createVector(cos(car.angle), sin(car.angle));
    let desiredCamPos = p5.Vector.sub(centerPos, p5.Vector.mult(forward, baseCamDist));
    
    let speedHeight = map(maxSpd, 0, 50, -180, -280);
    if (gameMode === 'COOP') speedHeight -= baseCamDist * 0.3; 

    camPos.x = lerp(camPos.x, desiredCamPos.x, 0.08);
    camPos.y = lerp(camPos.y, desiredCamPos.y, 0.08); 
    
    camera(camPos.x, speedHeight, camPos.y, centerPos.x, 0, centerPos.y, 0, 1, 0);
    
    updateSpeedText(round(car.vel.mag() * 3) + " mph");
    if (wantedStars > 0 || gameMode === 'COPNADO') processWantedEscalation();
    
    sfx.update(gas1, car.vel.mag(), car.isDrifting);
    sfx.playMusic();
    
    // Infinite map obstacles
    for (let obs of obstacles) {
      if (distSq(obs.x, obs.z, centerPos.x, centerPos.y) > 16000000) {
        let ang = random(TWO_PI); let r = random(2000, 3500);
        obs.x = centerPos.x + cos(ang)*r; obs.z = centerPos.y + sin(ang)*r;
      }
    }
    
    // Copnado Events
    if (gameMode === 'COPNADO' && tornado) {
      tornado.update();
      if (distSq(car.pos.x, car.pos.y, tornado.pos.x, tornado.pos.y) < 40000) {
        triggerGameOver('INTERCEPTED!', 'You successfully gathered data from the Copnado!');
        document.querySelector('.busted-box h1').style.color = '#33ff33';
        document.querySelector('.busted-box').style.borderColor = '#33ff33';
      }
    }
    
  } else if (gameState === 'ON_FOOT') {
    car.update(0, 0); updatePlayerFPS();
    let dSq = distSq(playerPos.x, playerPos.z, car.pos.x, car.pos.y);
    if (dSq < 22500) updateSpeedText("Press E to Enter Car");
    else updateSpeedText("ON FOOT");
    sfx.update(0, 0, false); sfx.playMusic();
  } else if (gameState === 'CUTSCENE_BUSTED') {
    let t = millis() - cutsceneTimer;
    let targetX = bustedOnFoot ? playerPos.x : car.pos.x;
    let targetZ = bustedOnFoot ? playerPos.z : car.pos.y;
    camera(targetX + cos(t * 0.001) * 300, -150, targetZ + sin(t * 0.001) * 300, targetX, 0, targetZ, 0, 1, 0);
    
    car.vel.mult(0.85); car.update(0, 0); 
    if(car2) { car2.vel.mult(0.85); car2.update(0,0); }
    sfx.muteAll();
    
    if (car.health <= 0 && !bustedOnFoot && random() > 0.3) {
      let forward = createVector(cos(car.angle), sin(car.angle));
      let enginePos = p5.Vector.add(car.pos, p5.Vector.mult(forward, 45));
      smokeParticles.push({
        x: enginePos.x + random(-15, 15), y: -10, z: enginePos.y + random(-15, 15),
        vx: random(-0.5, 0.5), vy: random(-4, -1), vz: random(-0.5, 0.5),
        life: 255, size: random(15, 35), isBlack: true
      });
    }

    if (t > 7000) skipCutscene();
  } else {
    car.update(0, 0);
    camera(car.pos.x + cos(millis() * 0.0003) * 600, -250, car.pos.y + sin(millis() * 0.0003) * 600, car.pos.x, 0, car.pos.y, 0, 1, 0);
    sfx.muteAll(); 
  }

  // Lighting Configuration
  if (gameState === 'CUTSCENE_BUSTED' && gameMode !== 'COPNADO') {
    if (millis() % 400 < 200) ambientLight(255, 50, 50); else ambientLight(50, 50, 255);
    directionalLight(200, 200, 200, -1, 1, -1);
  } else {
    ambientLight(120);
    directionalLight(255, 255, 255, 0.5, 1, -0.5); 
    pointLight(255, 255, 255, car.pos.x, -200, car.pos.y); 
  }
  
  drawGround(); drawSkidmarks(); drawObstacles(); drawSmoke();
  if (gameMode === 'COPNADO' && tornado) tornado.display();
  
  if (gameState === 'CUTSCENE_BUSTED' || gameState === 'GAMEOVER') {
    for (let npc of npcs) { npc.update(0, 0); npc.display(); }
    for (let c of cops) { c.update(0, 0); c.display(); }
  } else {
    updateNPCs(); updateCops();
  }
  
  if (gameState === 'PLAYING' || gameState === 'ON_FOOT') checkCollisions();
  
  car.display();
  if (car2) car2.display();
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Cutscene/Admin Early Returns if (gameState === 'CUSTOMIZER') {

Renders showroom-style cutscene cameras and returns early, skipping the normal gameplay rendering entirely

conditional Main Driving Logic if (gameState === 'PLAYING') {

Reads keyboard/touch input, updates both cars' physics, and computes the smooth chase camera

conditional On-Foot Mode } else if (gameState === 'ON_FOOT') {

Updates the first-person walking camera and checks if the player is near enough to their car to re-enter it

conditional Busted Cutscene } else if (gameState === 'CUTSCENE_BUSTED') {

Plays a scripted arrest cutscene with a slow orbiting camera, flashing lights, and engine smoke

for-loop Infinite World Recycling for (let obs of obstacles) {

Teleports obstacles that are far behind the player to a new random spot ahead, faking an infinite map with a fixed-size array

conditional Lighting Configuration if (gameState === 'CUTSCENE_BUSTED' && gameMode !== 'COPNADO') {

Switches between flashing red/blue police lighting during an arrest and normal daylight lighting otherwise

if (gameState === 'ADMIN_LOGIN' || gameState === 'ADMIN_PANEL') {
For the hidden admin screens, draw() just paints a dark purple background and exits immediately - no 3D game renders behind the menu
if (gameMode === 'COPNADO') { background(30, 40, 50); if (random() > 0.98) background(200); }
Copnado mode uses a stormy sky and randomly flashes near-white for a lightning effect roughly 2% of frames
car.update(gas1, steer1);
Feeds this frame's gas/steer input into the Car's physics update, moving it and checking drift/collisions
let desiredCamPos = p5.Vector.sub(centerPos, p5.Vector.mult(forward, baseCamDist));
Calculates the ideal camera position: directly behind the car(s), a fixed distance back along the car's facing direction
camPos.x = lerp(camPos.x, desiredCamPos.x, 0.08);
Smoothly moves the actual camera 8% of the way toward its ideal spot each frame, avoiding jittery snapping
if (wantedStars > 0 || gameMode === 'COPNADO') processWantedEscalation();
Only runs the cop-spawning/escalation logic once the player has at least 1 wanted star (or is in Copnado mode)
if (distSq(obs.x, obs.z, centerPos.x, centerPos.y) > 16000000) {
Checks if an obstacle is more than 4000 units away (16,000,000 is 4000 squared) and relocates it ahead of the player if so
drawGround(); drawSkidmarks(); drawObstacles(); drawSmoke();
Draws the world layer by layer: floor plane, tire marks, cone obstacles, then smoke particles on top
if (gameState === 'PLAYING' || gameState === 'ON_FOOT') checkCollisions();
Only checks for crashes and arrests while actively playing, not during cutscenes

Tornado (class)

The Tornado class is a self-contained game entity following the same constructor/update/display pattern as Car - a reusable structure for anything in the world that needs its own state and behavior.

class Tornado {
  constructor(x, z) {
    this.pos = createVector(x, z);
    this.angle = 0;
  }
  update() {
    this.angle += 0.2;
    let target = car.pos.copy();
    if (car2) target.add(car2.pos).mult(0.5);
    let dir = p5.Vector.sub(target, this.pos);
    dir.setMag(16); 
    this.pos.add(dir);
    for(let c of cops) {
      let d = dist(this.pos.x, this.pos.y, c.pos.x, c.pos.y);
      if (d < 1500) {
        let pull = p5.Vector.sub(this.pos, c.pos);
        pull.setMag(map(d, 0, 1500, 25, 0));
        c.vel.add(pull);
        c.angle += 0.2; 
      }
    }
  }
  display() {
    push();
    translate(this.pos.x, -20, this.pos.y);
    noStroke();
    for(let i = 0; i < 20; i++) {
      push();
      translate(0, -i * 35, 0);
      rotateY(this.angle * (1 + i * 0.05));
      fill(20, 20, 20, 180);
      cylinder(120 + i * 35, 40);
      pop();
    }
    pop();
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Chase Steering let dir = p5.Vector.sub(target, this.pos);

Points the tornado's movement vector toward whichever car(s) are currently active, then moves it at a constant speed of 16

for-loop Cop Suction Effect for(let c of cops) {

Pulls nearby cop cars toward the tornado and spins their angle, simulating them being sucked in

for-loop Funnel Segment Stack for(let i = 0; i < 20; i++) {

Stacks 20 cylinders of increasing radius and rotation offset on top of each other to form a swirling funnel shape

this.angle += 0.2;
Continuously spins the tornado's rotation each frame, used later to twist each funnel layer
dir.setMag(16);
Normalizes the direction toward the player and scales it to a fixed speed of 16 units/frame, so the tornado always chases at constant speed
pull.setMag(map(d, 0, 1500, 25, 0));
The closer a cop is to the tornado, the stronger the pull force applied to its velocity
rotateY(this.angle * (1 + i * 0.05));
Each funnel layer spins slightly faster than the one below it, creating a twisting spiral look
cylinder(120 + i * 35, 40);
Draws each layer wider than the last (120 plus 35 per layer) so the funnel flares outward toward the top

drawGround()

Because a single plane this large would need excessive precision to follow the car exactly, snapping its position to a grid keeps it numerically stable while still looking infinite.

function drawGround() { 
  push(); 
  let cx = floor(camPos.x / 1000) * 1000;
  let cz = floor(camPos.y / 1000) * 1000;
  translate(cx, 0, cz);
  fill(25); noStroke(); rotateX(HALF_PI); 
  plane(20000, 20000); 
  pop(); 
}
Line-by-line explanation (3 lines)
let cx = floor(camPos.x / 1000) * 1000;
Snaps the ground plane's position to the nearest 1000-unit grid cell based on the camera, so it always covers the visible area no matter how far the player travels
rotateX(HALF_PI);
Rotates the plane 90 degrees so it lies flat as a floor instead of standing up like a wall
plane(20000, 20000);
Draws one huge 20,000x20,000 unit dark gray floor plane centered on the snapped position

drawObstacles()

distSq() (squared distance) is used instead of dist() here purely for performance - comparing squared distances avoids an expensive square root calculation for every one of the 60 obstacles, every frame.

function drawObstacles() {
  noStroke(); 
  let checkPos = gameState === 'ON_FOOT' ? playerPos : createVector(car.pos.x, car.pos.y, car.pos.y);
  
  for (let obs of obstacles) {
    if (distSq(checkPos.x, checkPos.y, obs.x, obs.z) < 4000000) { 
      push(); 
      translate(obs.x, -20, obs.z); 
      rotateX(PI); 
      fill(obs.color); 
      cone(12, 40, 6, 1); 
      translate(0, 10, 0); 
      fill(255); 
      cylinder(8, 10, 6, 1); 
      pop(); 
    }
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Distance Culling if (distSq(checkPos.x, checkPos.y, obs.x, obs.z) < 4000000) {

Skips drawing any obstacle further than 2000 units away, saving performance by only rendering nearby cones

let checkPos = gameState === 'ON_FOOT' ? playerPos : createVector(car.pos.x, car.pos.y, car.pos.y);
Culling is measured from the walking player's position when on foot, or from the car otherwise
cone(12, 40, 6, 1);
Draws the colored traffic-cone body: 12 radius, 40 tall, 6-sided
cylinder(8, 10, 6, 1);
Draws a small white cylinder cap on top of the cone for the glowing tip effect

drawSkidmarks()

Skidmarks are just an array of {x, z} points pushed by the Car whenever it drifts - drawGround() draws first, so these dark squares render on top of the floor.

function drawSkidmarks() { 
  fill(15); noStroke(); 
  for(let s of skidmarks) { 
    push(); translate(s.x, -0.5, s.z); rotateX(HALF_PI); plane(12, 12, 1, 1); pop(); 
  } 
}
Line-by-line explanation (2 lines)
for(let s of skidmarks) {
Loops through every stored skidmark point and draws a tiny dark square there, flat on the ground
translate(s.x, -0.5, s.z);
Places the skidmark slightly above the ground plane (-0.5) to avoid z-fighting flicker with the floor

drawSmoke()

This is a classic 'particle system' pattern: an array of plain objects with position, velocity, and life, updated and culled every frame - the same technique scales to fire, sparks, rain, or confetti effects.

🔬 The life decreases by 15 each frame. What happens to how long smoke lingers if you change that to 5? To 40?

    let s = smokeParticles[i]; s.x += s.vx; s.y += s.vy; s.z += s.vz; s.life -= 15; s.size += 0.8;
function drawSmoke() {
  noStroke();
  for (let i = smokeParticles.length - 1; i >= 0; i--) {
    let s = smokeParticles[i]; s.x += s.vx; s.y += s.vy; s.z += s.vz; s.life -= 15; s.size += 0.8;
    push(); translate(s.x, s.y, s.z); 
    if (s.isBlack) fill(30, 30, 30, constrain(s.life, 0, 200)); else fill(220, 220, 220, constrain(s.life, 0, 150)); 
    rotateX(HALF_PI); plane(s.size, s.size, 1, 1); pop();
    if (s.life <= 0) smokeParticles.splice(i, 1);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Reverse Particle Update Loop for (let i = smokeParticles.length - 1; i >= 0; i--) {

Iterates backward through the smoke array so particles can be safely removed with splice() mid-loop without skipping elements

s.x += s.vx; s.y += s.vy; s.z += s.vz; s.life -= 15; s.size += 0.8;
Moves each smoke particle by its velocity, fades its life down, and grows its size slightly every frame
if (s.isBlack) fill(30, 30, 30, constrain(s.life, 0, 200)); else fill(220, 220, 220, constrain(s.life, 0, 150));
Engine failure smoke is drawn dark, while normal tire smoke is drawn light gray, both fading out as life drops
if (s.life <= 0) smokeParticles.splice(i, 1);
Removes a particle from the array entirely once it has fully faded, so the array doesn't grow forever

drawHuman()

drawHuman() is a tiny low-poly character builder used only during the arrest cutscene, showing how two stacked box() shapes and a pivot translate can fake a simple human figure in 3D.

function drawHuman(x, z, rotY, rotX, shirtColor) {
  push(); translate(x, -20, z); rotateY(-rotY); translate(0, 10, 0); rotateX(rotX); translate(0, -10, 0); fill(shirtColor); box(12, 16, 8); 
  translate(0, -12, 0); fill(255, 220, 180); box(8, 8, 8); pop();
}
Line-by-line explanation (4 lines)
translate(x, -20, z); rotateY(-rotY);
Moves to the human's world position and rotates them to face the given direction
translate(0, 10, 0); rotateX(rotX); translate(0, -10, 0);
A translate-rotate-translate 'pivot trick': shifts down, tips the body forward/back for the arrest animation, then shifts back so the rotation pivots around the hips instead of the origin
fill(shirtColor); box(12, 16, 8);
Draws a simple box for the torso in the given shirt color
translate(0, -12, 0); fill(255, 220, 180); box(8, 8, 8);
Draws a small skin-colored box above the torso as the head

skipCutscene()

This same function is called both from a UI button click and automatically by draw() once a cutscene's timer runs out, avoiding duplicated transition logic.

function skipCutscene() {
  document.getElementById('btn-skip-cutscene').style.display = 'none';
  if (gameState === 'CUTSCENE_BUY_CAR') {
    gameState = 'SHOP'; updateShopUI(); showScreen('shop-screen');
  } else if (gameState === 'CUTSCENE_BUSTED') {
    gameState = 'GAMEOVER'; showScreen('busted-screen');
  }
}
Line-by-line explanation (2 lines)
if (gameState === 'CUTSCENE_BUY_CAR') {
If the player skips the showroom cutscene, jump straight back to the shop screen
} else if (gameState === 'CUTSCENE_BUSTED') {
If the player skips the arrest cutscene, jump straight to the GAMEOVER busted screen

processWantedEscalation()

This function only runs once you already have at least one wanted star (checked in draw()), and it's what turns a simple crash into an escalating GTA-style chase over time.

🔬 These numbers are seconds (60 frames = 1 sec) before each star kicks in. What happens if you divide every threshold by 4 so the chase escalates much faster?

    if (chaseFrames > 60 * 15) neededLevel = 2;
    if (chaseFrames > 60 * 35) neededLevel = 3;
    if (chaseFrames > 60 * 60) neededLevel = 4;
    if (chaseFrames > 60 * 90) neededLevel = 5;
function processWantedEscalation() {
  if (gameMode === 'COPNADO') { wantedStars = 5; updateWantedUI(); }
  else {
    chaseFrames++; let neededLevel = 1;
    if (chaseFrames > 60 * 15) neededLevel = 2;
    if (chaseFrames > 60 * 35) neededLevel = 3;
    if (chaseFrames > 60 * 60) neededLevel = 4;
    if (chaseFrames > 60 * 90) neededLevel = 5;
    if (neededLevel > wantedStars) { wantedStars = neededLevel; updateWantedUI(); }
  }
  let maxCops = (gameMode === 'COPNADO') ? 15 : 2 + (wantedStars * 2); 
  if (cops.length < maxCops && frameCount % 60 === 0) spawnCop();
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Star Level Thresholds if (chaseFrames > 60 * 15) neededLevel = 2;

Escalates the wanted level every 15/35/60/90 seconds spent being chased, since 60 frames = roughly 1 second

if (gameMode === 'COPNADO') { wantedStars = 5; updateWantedUI(); }
Copnado mode is always max wanted level - the tornado itself is the threat, not gradual escalation
chaseFrames++; let neededLevel = 1;
Counts how many frames the player has been wanted, used to determine how many stars they've earned
let maxCops = (gameMode === 'COPNADO') ? 15 : 2 + (wantedStars * 2);
Calculates how many cops are allowed to exist at once, scaling with the current wanted level
if (cops.length < maxCops && frameCount % 60 === 0) spawnCop();
Spawns one new cop roughly once per second, but only if under the current cop cap

updateWantedUI()

This is a small but handy UI pattern: building a display string with a loop and conditional rather than hardcoding five separate star elements in HTML.

function updateWantedUI() {
  let starStr = "";
  for (let i=0; i<5; i++) starStr += (i < wantedStars) ? "★" : "☆";
  let wAlert = document.getElementById('wanted-alert');
  if (wAlert) wAlert.innerText = "WANTED " + starStr;
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Star String Builder for (let i=0; i<5; i++) starStr += (i < wantedStars) ? "★" : "☆";

Builds a 5-character string of filled and empty stars representing the current wanted level

starStr += (i < wantedStars) ? "★" : "☆";
For each of the 5 positions, adds a filled star if it's within the current wanted level, otherwise an empty star outline
wAlert.innerText = "WANTED " + starStr;
Writes the resulting star string into the HTML wanted-level display in the corner of the screen

spawnCop()

Building a pool array and pushing duplicate entries for rarer/tougher enemies is a simple way to implement weighted random selection without needing a full probability table.

function spawnCop() {
  let angle = car.angle + PI + random(-1.5, 1.5);
  if(gameMode === 'COPNADO' && tornado) {
    angle = random(TWO_PI);
    let cx = tornado.pos.x + cos(angle)*1000; let cz = tornado.pos.y + sin(angle)*1000;
    let cop = new Car(cx, cz, color(255));
    cop.isCop = true; cop.copType = 'COP'; cop.maxSpeed = 38; cop.accel = 0.8; cop.mass = 1.0;
    cops.push(cop); return;
  }
  
  let cx = car.pos.x + cos(angle) * 1500; let cz = car.pos.y + sin(angle) * 1500;
  let pool = ['COP'];
  if (wantedStars >= 2) pool.push('SHERIFF', 'COP');
  if (wantedStars >= 3) pool.push('SWAT');
  if (wantedStars >= 4) pool.push('MILITARY');
  if (wantedStars >= 5) pool.push('SPECIAL_FORCES', 'SPECIAL_FORCES');
  
  let type = random(pool);
  let cop = new Car(cx, cz, color(255));
  cop.isCop = true; cop.copType = type;
  
  if (type === 'COP') { cop.maxSpeed = 38; cop.accel = 0.8; cop.mass = 1.0; }
  else if (type === 'SHERIFF') { cop.maxSpeed = 39; cop.accel = 0.85; cop.mass = 1.1; }
  else if (type === 'SWAT') { cop.maxSpeed = 34; cop.accel = 0.9; cop.mass = 2.5; }
  else if (type === 'MILITARY') { cop.maxSpeed = 32; cop.accel = 0.7; cop.mass = 3.5; }
  else if (type === 'SPECIAL_FORCES') { cop.maxSpeed = 46; cop.accel = 1.1; cop.mass = 1.5; }
  
  cops.push(cop);
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Weighted Cop Type Pool if (wantedStars >= 2) pool.push('SHERIFF', 'COP');

Builds a weighted list of possible cop types based on wanted level, so tougher units become more likely (and possible) at higher stars

switch-case Cop Type Stat Assignment if (type === 'COP') { cop.maxSpeed = 38; cop.accel = 0.8; cop.mass = 1.0; }

Assigns different speed/acceleration/mass values depending on which cop type was randomly chosen

let angle = car.angle + PI + random(-1.5, 1.5);
Picks a spawn direction roughly behind the player (angle + PI is the opposite direction) with some randomness
let cx = car.pos.x + cos(angle) * 1500; let cz = car.pos.y + sin(angle) * 1500;
Converts the chosen angle and a 1500-unit distance into an actual x/z spawn position
let type = random(pool);
Randomly picks one entry from the weighted pool array - types listed more than once (like SPECIAL_FORCES at 5 stars) are proportionally more likely

triggerWanted()

This is called from three different places - drifting, crashing into an NPC, and crashing into a cop - all funneling into one shared 'become wanted' entry point.

function triggerWanted() {
  if (wantedStars === 0 && gameMode !== 'COPNADO') {
    wantedStars = 1;
    let wAlert = document.getElementById('wanted-alert');
    if (wAlert) wAlert.style.display = 'block';
    updateWantedUI();
    for(let i=0; i<3; i++) spawnCop();
  }
}
Line-by-line explanation (2 lines)
if (wantedStars === 0 && gameMode !== 'COPNADO') {
Only triggers the first time you go from 0 to 1 star - repeated crashes after that don't re-trigger this
for(let i=0; i<3; i++) spawnCop();
Immediately spawns 3 cops the moment you first become wanted, instead of waiting for the usual 1-per-second spawn timer

SoundManager (class)

SoundManager shows how to build simple game audio entirely with p5.sound's Oscillator, Noise, and Envelope objects instead of loading audio files - useful when you want procedural sound that reacts instantly to gameplay values like speed.

class SoundManager {
  constructor() {
    this.started = false;
    this.engineOsc = new p5.Oscillator('sawtooth'); this.engineOsc.amp(0); this.engineOsc.freq(60);
    this.skidOsc = new p5.Oscillator('square'); this.skidOsc.amp(0); this.skidOsc.freq(800);
    this.crashNoise = new p5.Noise('white');
    this.crashEnv = new p5.Envelope(); this.crashEnv.setADSR(0.01, 0.1, 0.2, 0.3); this.crashEnv.setRange(0.6, 0);
    this.crashNoise.amp(this.crashEnv);
    this.musicOsc = new p5.Oscillator('square'); this.musicOsc.amp(0);
    this.musicEnv = new p5.Envelope(); this.musicEnv.setADSR(0.05, 0.1, 0.0, 0.0); this.musicEnv.setRange(0.08, 0);
    this.musicOsc.amp(this.musicEnv);
    this.notes = [130.81, 155.56, 196.00, 155.56]; this.musicStep = 0;
  }
  start() {
    if (!this.started) {
      this.engineOsc.start(); this.skidOsc.start(); this.crashNoise.start(); this.musicOsc.start(); this.started = true;
    }
  }
  update(gas, speed, isDrifting) {
    if (!this.started) return;
    let targetFreq = map(speed, 0, 45, 50, 260); let targetAmp = abs(gas) > 0 ? 0.15 : 0.05;
    this.engineOsc.freq(targetFreq, 0.1); this.engineOsc.amp(targetAmp, 0.1);
    if (isDrifting) { this.skidOsc.amp(0.08, 0.05); this.skidOsc.freq(random(700, 1000)); } else { this.skidOsc.amp(0, 0.2); }
  }
  playCrash() { if (this.started) this.crashEnv.play(); }
  playMusic() {
    if (!this.started) return;
    if (frameCount % 12 === 0) {
      let f = this.notes[this.musicStep % this.notes.length];
      if (wantedStars > 0) f *= (1 + (wantedStars * 0.1)); 
      this.musicOsc.freq(f); this.musicEnv.play(); this.musicStep++;
    }
  }
  muteAll() { if (!this.started) return; this.engineOsc.amp(0, 0.5); this.skidOsc.amp(0, 0.5); }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Engine Pitch Mapping let targetFreq = map(speed, 0, 45, 50, 260);

Maps the car's current speed to an oscillator frequency so the engine sound pitches up as you accelerate

conditional Bassline Sequencer if (frameCount % 12 === 0) {

Plays the next note in a 4-note bassline loop every 12 frames, pitched higher when the wanted level rises

this.engineOsc = new p5.Oscillator('sawtooth');
Creates a raw sawtooth wave oscillator used to synthesize an engine-like drone, with no audio files needed
this.crashEnv.setADSR(0.01, 0.1, 0.2, 0.3);
Configures the crash sound's attack/decay/sustain/release envelope - a fast attack and short decay gives it a punchy 'thud' shape
this.notes = [130.81, 155.56, 196.00, 155.56];
A short 4-note loop (in Hz) that forms the game's simple looping bassline
if (wantedStars > 0) f *= (1 + (wantedStars * 0.1));
Raises the music's pitch as the wanted level increases, adding tension during a chase
muteAll() { if (!this.started) return; this.engineOsc.amp(0, 0.5); this.skidOsc.amp(0, 0.5); }
Fades the engine and skid sounds to silence over half a second, used whenever the player isn't actively driving

updateSpeedText()

This 'cache the last value' pattern (repeated for cash and health too) is a small but important performance habit when mixing a 60fps canvas loop with DOM text updates.

function updateSpeedText(text) {
  if (lastSpeedText !== text) {
    let sd = document.getElementById('speed-display'); if (sd) sd.innerText = text; lastSpeedText = text;
  }
}
Line-by-line explanation (1 lines)
if (lastSpeedText !== text) {
Only touches the DOM if the displayed text actually changed, avoiding unnecessary reflows every single frame

updateHealthUI()

Driving a CSS width percentage directly from a game variable is a lightweight way to build UI bars without any extra libraries.

function updateHealthUI(health) {
  let pct = max(0, health);
  if (pct !== lastHealth) {
    let hFill = document.getElementById('health-fill'); if (hFill) hFill.style.width = pct + '%'; lastHealth = pct;
  }
}
Line-by-line explanation (2 lines)
let pct = max(0, health);
Clamps negative health values to 0 so the health bar never shows a negative width
hFill.style.width = pct + '%';
Directly sets the CSS width percentage of the health bar fill element to visually represent remaining health

updateCashUI()

Keeping two separate on-screen cash displays in sync from one function avoids duplicating update logic anywhere cash changes (drifting, crashing, buying upgrades).

function updateCashUI() {
  if (cash !== lastCash) {
    let cashDisp = document.getElementById('cash-display');
    let shopCash = document.getElementById('shop-cash-display');
    if (cashDisp) cashDisp.innerText = "$" + cash;
    if (shopCash) shopCash.innerText = cash;
    lastCash = cash;
  }
}
Line-by-line explanation (3 lines)
if (cash !== lastCash) {
Skips updating the DOM entirely if cash hasn't changed since the last check
if (cashDisp) cashDisp.innerText = "$" + cash;
Updates the in-game HUD cash readout
if (shopCash) shopCash.innerText = cash;
Also updates the separate cash display shown inside the shop screen

distSq()

This tiny helper is called dozens of times per frame across collision checks, obstacle culling, and AI targeting - avoiding sqrt() in hot loops like this is a common game-performance trick.

function distSq(x1, y1, x2, y2) { return (x1 - x2) ** 2 + (y1 - y2) ** 2; }
Line-by-line explanation (1 lines)
return (x1 - x2) ** 2 + (y1 - y2) ** 2;
Computes squared distance between two points - skipping the square root that dist() would normally do, since comparing squared values is enough for 'is X closer than Y' checks

updatePlayerFPS()

This function implements a classic first-person camera: mouse deltas drive yaw/pitch angles, and trigonometry converts those angles into a 3D look-at point for p5's camera() function.

function updatePlayerFPS() {
  if (document.pointerLockElement) {
    playerYaw += movedX * 0.003; playerPitch -= movedY * 0.003;
    playerPitch = constrain(playerPitch, -PI/2 + 0.1, PI/2 - 0.1);
  } else {
    if (touchInput.left) playerYaw -= 0.05; if (touchInput.right) playerYaw += 0.05;
  }
  let speed = 8; let moveX = 0; let moveZ = 0;
  if (keyIsDown(87) || keyIsDown(UP_ARROW) || touchInput.up) { moveX += cos(playerYaw) * speed; moveZ += sin(playerYaw) * speed; }
  if (keyIsDown(83) || keyIsDown(DOWN_ARROW) || touchInput.down) { moveX -= cos(playerYaw) * speed; moveZ -= sin(playerYaw) * speed; }
  if (keyIsDown(65)) { moveX += cos(playerYaw - HALF_PI) * speed; moveZ += sin(playerYaw - HALF_PI) * speed; }
  if (keyIsDown(68)) { moveX += cos(playerYaw + HALF_PI) * speed; moveZ += sin(playerYaw + HALF_PI) * speed; }
  playerPos.x += moveX; playerPos.z += moveZ;
  let lookX = playerPos.x + cos(playerYaw) * cos(playerPitch);
  let lookY = playerPos.y + sin(playerPitch);
  let lookZ = playerPos.z + sin(playerYaw) * cos(playerPitch);
  camera(playerPos.x, playerPos.y, playerPos.z, lookX, lookY, lookZ, 0, 1, 0);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Mouse Look via Pointer Lock if (document.pointerLockElement) {

Uses the browser's Pointer Lock API mouse-delta values (movedX/movedY) to rotate the view, just like a typical FPS game

conditional WASD Strafe Movement if (keyIsDown(65)) { moveX += cos(playerYaw - HALF_PI) * speed; moveZ += sin(playerYaw - HALF_PI) * speed; }

Moves the player perpendicular to their facing direction (strafing) when pressing A or D

playerYaw += movedX * 0.003; playerPitch -= movedY * 0.003;
Converts raw mouse movement into rotation - horizontal mouse motion turns you left/right, vertical motion tilts your view up/down
playerPitch = constrain(playerPitch, -PI/2 + 0.1, PI/2 - 0.1);
Prevents the camera from flipping upside-down by clamping how far up or down you can look
let lookX = playerPos.x + cos(playerYaw) * cos(playerPitch);
Calculates a point slightly ahead of the player in the direction they're facing, which camera() uses as its look-at target
camera(playerPos.x, playerPos.y, playerPos.z, lookX, lookY, lookZ, 0, 1, 0);
Positions the 3D camera at the player's feet-level position, looking toward the calculated forward point

mousePressed()

requestPointerLock() is a browser API (wrapped by p5) that hides the cursor and gives you raw, unbounded mouse movement deltas - essential for any first-person camera control.

function mousePressed() { if (gameState === 'ON_FOOT') requestPointerLock(); }
Line-by-line explanation (1 lines)
if (gameState === 'ON_FOOT') requestPointerLock();
Clicking the canvas while walking around locks the mouse cursor to enable FPS-style mouse look

keyPressed()

keyPressed() fires once per key-down event (unlike keyIsDown() which is checked continuously in draw()), making it ideal for one-shot actions like toggling states or opening menus.

function keyPressed() { 
  if (key === 'e' || key === 'E') toggleVehicle(); 
  
  if (gameState === 'CUTSCENE_BUY_CAR' || gameState === 'CUTSCENE_BUSTED') {
    if (key === ' ' || key === 'Enter') skipCutscene();
  }
  
  // CHANGED: Admin Panel hotkey is now 'q' or 'Q'
  if ((key === 'q' || key === 'Q') && gameState !== 'ADMIN_LOGIN' && gameState !== 'ADMIN_PANEL') {
    gameState = 'ADMIN_LOGIN'; showScreen('admin-login-screen');
    document.getElementById('admin-pass').value = '';
  }
}
Line-by-line explanation (3 lines)
if (key === 'e' || key === 'E') toggleVehicle();
Pressing E always tries to enter or exit the car, regardless of current game state
if (key === ' ' || key === 'Enter') skipCutscene();
Space or Enter skips the currently playing cutscene
if ((key === 'q' || key === 'Q') && gameState !== 'ADMIN_LOGIN' && gameState !== 'ADMIN_PANEL') {
A hidden hotkey that opens a PIN-protected admin/cheat panel, guarded so it can't be reopened while already open

toggleVehicle()

This function is the bridge between the driving and on-foot game modes, translating car-relative positions into world positions for the walking player and back again.

function toggleVehicle() {
  if (gameState === 'PLAYING') {
    gameState = 'ON_FOOT';
    let crosshair = document.getElementById('crosshair'); if (crosshair) crosshair.style.display = 'block';
    let exitOffset = p5.Vector.fromAngle(car.angle - HALF_PI).mult(80);
    playerPos.x = car.pos.x + exitOffset.x; playerPos.z = car.pos.y + exitOffset.y; playerPos.y = -40; 
    playerYaw = car.angle; playerPitch = 0;
  } else if (gameState === 'ON_FOOT') {
    if (distSq(playerPos.x, playerPos.z, car.pos.x, car.pos.y) < 22500) {
      gameState = 'PLAYING';
      let crosshair = document.getElementById('crosshair'); if (crosshair) crosshair.style.display = 'none';
      exitPointerLock();
    }
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Exit Vehicle if (gameState === 'PLAYING') {

Places the player standing beside the car's left door, facing the same direction the car was pointing

conditional Re-enter Vehicle } else if (gameState === 'ON_FOOT') {

Only allows re-entering the car if the player is standing within 150 units of it (22500 is 150 squared)

let exitOffset = p5.Vector.fromAngle(car.angle - HALF_PI).mult(80);
Builds a vector pointing 90 degrees left of the car's facing direction, scaled to 80 units, used to place the player beside the door
if (distSq(playerPos.x, playerPos.z, car.pos.x, car.pos.y) < 22500) {
Compares squared distance against 150-squared to check if the player is close enough to the car to get back in

handleCrash()

This is a simplified mass-based impulse collision response: instead of full physics engine math, it just nudges velocities apart proportional to relative mass, which feels convincing without heavy computation.

function handleCrash(c1, c2) {
  let bounceDir = p5.Vector.sub(c1.pos, c2.pos).normalize();
  let m1 = c1.mass || 1; let m2 = c2.mass || 1;
  c1.vel.add(p5.Vector.mult(bounceDir, 8 * (m2 / m1)));
  c2.vel.sub(p5.Vector.mult(bounceDir, 8 * (m1 / m2)));
  if (c1.isPlayer || c2.isPlayer) sfx.playCrash(); 
  if (c1.isPlayer) c1.takeDamage(15 * m2);
  if (c2.isPlayer) c2.takeDamage(15 * m1);
}
Line-by-line explanation (3 lines)
let bounceDir = p5.Vector.sub(c1.pos, c2.pos).normalize();
Computes the direction pointing from car2 toward car1, used to push them apart
c1.vel.add(p5.Vector.mult(bounceDir, 8 * (m2 / m1)));
Pushes car1 away, scaled by the other car's mass relative to its own - heavier opponents knock you back harder
if (c1.isPlayer) c1.takeDamage(15 * m2);
Only player-controlled cars take health damage from crashes; NPCs and cops just bounce

spawnNPC()

Spawning NPCs ahead of the player rather than at fixed world coordinates keeps traffic relevant no matter which direction the infinite world has scrolled.

function spawnNPC() {
  let aheadVec = p5.Vector.fromAngle(car.angle).mult(random(1000, 2000));
  let spawnPos = p5.Vector.add(car.pos, aheadVec); spawnPos.add(createVector(random(-500, 500), random(-500, 500)));
  let npcColor = color(random(50, 255), random(50, 255), random(50, 255));
  let npc = new Car(spawnPos.x, spawnPos.y, npcColor); npc.aiTimer = random(0, 1000); npcs.push(npc);
}
Line-by-line explanation (2 lines)
let aheadVec = p5.Vector.fromAngle(car.angle).mult(random(1000, 2000));
Creates a vector pointing in the direction the player is driving, scaled to a random distance 1000-2000 units ahead
npc.aiTimer = random(0, 1000);
Randomizes each NPC's starting AI timer so their weaving patterns aren't all synchronized

updateNPCs()

This is a lightweight steering-behavior AI: rather than pathfinding, a sine wave over an internal timer is enough to make traffic feel alive without any complex logic.

🔬 The 0.02 controls how fast NPCs weave and the 1.5 controls how sharply. What happens if you raise 1.5 to 4?

    npc.aiTimer += 1; let aiSteer = sin(npc.aiTimer * 0.02) * 1.5; 
    npc.update(1, constrain(aiSteer, -1, 1)); npc.display();
function updateNPCs() {
  for (let npc of npcs) {
    if (distSq(car.pos.x, car.pos.y, npc.pos.x, npc.pos.y) > 12250000) {
      let aheadVec = p5.Vector.fromAngle(car.angle).mult(random(1500, 2500));
      npc.pos = p5.Vector.add(car.pos, aheadVec); npc.pos.add(createVector(random(-1000, 1000), random(-1000, 1000))); npc.vel.set(0, 0);
    }
    npc.aiTimer += 1; let aiSteer = sin(npc.aiTimer * 0.02) * 1.5; 
    npc.update(1, constrain(aiSteer, -1, 1)); npc.display();
  }
}
Line-by-line explanation (1 lines)

🔧 Subcomponents:

conditional NPC Recycling if (distSq(car.pos.x, car.pos.y, npc.pos.x, npc.pos.y) > 12250000) {

Teleports NPCs that fall more than 3500 units behind back to ahead of the player, keeping traffic density constant

calculation Sine-Wave Weaving AI let aiSteer = sin(npc.aiTimer * 0.02) * 1.5;

Uses a sine wave over time to make NPC cars gently weave left and right instead of driving in a perfectly straight line

npc.update(1, constrain(aiSteer, -1, 1));
Always feeds full gas (1) plus the clamped weave steering value into the NPC's own Car physics update

updateCops()

This 'seek' AI pattern - point toward the target, always accelerate, and steer proportional to angle error - is one of the simplest and most common chase behaviors in game AI.

function updateCops() {
  for (let c of cops) {
    let targetX = (gameState === 'ON_FOOT') ? playerPos.x : car.pos.x;
    let targetY = (gameState === 'ON_FOOT') ? playerPos.z : car.pos.y;
    let desiredAngle = atan2(targetY - c.pos.y, targetX - c.pos.x);
    let angleDiff = desiredAngle - c.angle;
    while (angleDiff > PI) angleDiff -= TWO_PI; while (angleDiff < -PI) angleDiff += TWO_PI;
    c.update(1, constrain(angleDiff * 3, -1, 1)); c.display();
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

while-loop Angle Wrapping while (angleDiff > PI) angleDiff -= TWO_PI; while (angleDiff < -PI) angleDiff += TWO_PI;

Keeps the angle difference between -PI and PI so cops always turn the shortest way toward the player instead of spinning the long way around

let desiredAngle = atan2(targetY - c.pos.y, targetX - c.pos.x);
Calculates the exact angle from a cop's position toward the player using atan2, the standard way to get a direction angle between two points
c.update(1, constrain(angleDiff * 3, -1, 1));
Cops always floor the gas, and steer proportionally to how far off-angle they are from the player, clamped to the normal -1 to 1 steering range

checkCollisions()

checkCollisions() is called once per frame and centralizes every distance-based interaction in the game - a common pattern that keeps collision logic in one predictable place rather than scattered across many objects.

function checkCollisions() {
  for (let npc of npcs) {
    if (distSq(car.pos.x, car.pos.y, npc.pos.x, npc.pos.y) < 2500) { handleCrash(car, npc); if (wantedStars === 0) triggerWanted(); }
    if (car2 && distSq(car2.pos.x, car2.pos.y, npc.pos.x, npc.pos.y) < 2500) { handleCrash(car2, npc); if (wantedStars === 0) triggerWanted(); }
  }
  for (let c of cops) {
    if (distSq(car.pos.x, car.pos.y, c.pos.x, c.pos.y) < 3000) handleCrash(car, c);
    if (car2 && distSq(car2.pos.x, car2.pos.y, c.pos.x, c.pos.y) < 3000) handleCrash(car2, c);
    if (gameState === 'ON_FOOT' && distSq(playerPos.x, playerPos.z, c.pos.x, c.pos.y) < 4000) { triggerGameOver('BUSTED!', 'A cop tackled you to the ground!'); }
  }
  if (car2 && distSq(car.pos.x, car.pos.y, car2.pos.x, car2.pos.y) < 2500) handleCrash(car, car2);
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop NPC Collision Check for (let npc of npcs) {

Checks whether either player's car is close enough to any NPC to count as a crash, triggering wanted status the first time

for-loop Cop Collision & Tackle Check for (let c of cops) {

Handles car-vs-cop crashes and also checks if a cop got close enough to tackle the player while on foot

if (distSq(car.pos.x, car.pos.y, npc.pos.x, npc.pos.y) < 2500) { handleCrash(car, npc); if (wantedStars === 0) triggerWanted(); }
A distance under 50 units (2500 is 50 squared) counts as a collision, triggering both a physical bounce and starting the wanted meter
if (gameState === 'ON_FOOT' && distSq(playerPos.x, playerPos.z, c.pos.x, c.pos.y) < 4000) {
While walking, getting within about 63 units of a cop ends the game in an instant tackle/arrest

Car (class)

The Car class is used for four different roles - the player, player 2, every NPC, and every cop type - all sharing the same physics and rendering code but customizing stats and copType to change behavior and appearance. This is a great example of code reuse through a single flexible class rather than four separate ones.

class Car {
  constructor(x, z, carColor) {
    this.pos = createVector(x, z); this.vel = createVector(0, 0); this.angle = 0; this.steerAngle = 0;           
    this.color = carColor; this.isCop = false; this.copType = 'NONE'; this.isPlayer = false; this.health = 100;
    this.isDrifting = false; this.mass = 1.0; this.accel = 0.6; this.maxSpeed = 35; this.friction = 0.97;
    this.turnSpeed = 0.06; this.grip = 0.15; this.driftGrip = 0.03; this.customStats = null;
  }

  takeDamage(amount) {
    let activeArmor = this.customStats ? this.customStats.armorTier : playerStats.armorTier;
    let dmgMultiplier = (this.isPlayer) ? max(0.15, 1.1 - (activeArmor * 0.09)) : 1;
    this.health -= amount * dmgMultiplier;
    if(this === car) updateHealthUI(this.health);
    if (this.health <= 0 && gameState === 'PLAYING') { triggerGameOver('ENGINE FAILED!', 'Your engine blew out! Prepare to get tackled.'); }
  }

  update(gas, steerInput) {
    this.steerAngle = lerp(this.steerAngle, steerInput * PI / 4, 0.2);
    let speed = this.vel.mag(); let forward = createVector(cos(this.angle), sin(this.angle)); let isMovingForward = this.vel.dot(forward) >= 0;
    this.vel.add(p5.Vector.mult(forward, gas * this.accel)); this.vel.mult(this.friction);
    if (speed > 1.0) {
      let turnEffect = steerInput * this.turnSpeed;
      if (!isMovingForward) turnEffect *= -1; 
      turnEffect *= map(speed, 0, this.maxSpeed, 1.2, 0.5); this.angle += turnEffect;
    }
    forward = createVector(cos(this.angle), sin(this.angle));
    let desiredVel = p5.Vector.mult(forward, speed * (isMovingForward ? 1 : -1)); let currentTraction = this.grip;
    if (abs(steerInput) > 0 && speed > 15) currentTraction = this.driftGrip;
    this.vel.lerp(desiredVel, currentTraction);
    let slipAmount = p5.Vector.dist(this.vel, desiredVel); this.isDrifting = (slipAmount > 2.5 && speed > 5);
    
    if (this.isDrifting) {
      this.generateSkidmarks();
      if (random() > 0.6 && smokeParticles.length < 20) this.generateSmoke();
      if (this.isPlayer) { cash += 2; updateCashUI(); if (wantedStars === 0) triggerWanted(); }
    }
    this.pos.add(this.vel); this.vel.limit(this.maxSpeed); this.checkObstacleCollisions();
  }

  generateSkidmarks() {
    let forward = createVector(cos(this.angle), sin(this.angle)); let right = createVector(-sin(this.angle), cos(this.angle));
    let rearCenter = p5.Vector.sub(this.pos, p5.Vector.mult(forward, 40));
    let rl = p5.Vector.sub(rearCenter, p5.Vector.mult(right, 22)); let rr = p5.Vector.add(rearCenter, p5.Vector.mult(right, 22));
    skidmarks.push({ x: rl.x, z: rl.y }); skidmarks.push({ x: rr.x, z: rr.y });
    if (skidmarks.length > 30) skidmarks.splice(0, 2);
  }

  generateSmoke() {
    let forward = createVector(cos(this.angle), sin(this.angle)); let rc = p5.Vector.sub(this.pos, p5.Vector.mult(forward, 45));
    smokeParticles.push({
      x: rc.x + random(-20, 20), y: -5, z: rc.y + random(-20, 20),
      vx: this.vel.x * 0.2 + random(-1, 1), vy: random(-3, -1), vz: this.vel.y * 0.2 + random(-1, 1),
      life: 255, size: random(10, 25), isBlack: false
    });
  }

  checkObstacleCollisions() {
    for (let obs of obstacles) {
      if (distSq(this.pos.x, this.pos.y, obs.x, obs.z) < 1600) {
        let bounceDir = createVector(this.pos.x - obs.x, this.pos.y - obs.z).normalize();
        this.vel.add(bounceDir.mult(6)); this.vel.mult(0.5);
        if (this.isPlayer) { this.takeDamage(2); sfx.playCrash(); }
      }
    }
  }

  display() {
    push();
    translate(this.pos.x, -15, this.pos.y);
    rotateY(-this.angle); 

    let stats = this.customStats || playerStats;
    noStroke();
    specularMaterial(255); shininess(20);
    
    let chassisH = 20, chassisW = 50, chassisL = 100;
    
    if (this.isCop) {
      // ... unique box/cylinder models for COP, SHERIFF, SWAT, MILITARY, SPECIAL_FORCES
    } else {
      fill(this.color); box(chassisL, chassisH, chassisW);
      // ... engine scoop scales with stats.engineLevel
      // ... armorTier >= 2 through 10 each bolt on extra boxes/cones/cylinders (side skirts, roll cage, spoiler, bumpers, roof lights, exhaust pipes...)
    }
    
    // Headlights/taillights
    push(); translate(chassisL/2 + 1, -5, chassisW/3); fill(255); box(2, 6, 8); pop();
    push(); translate(-chassisL/2 - 1, -5, chassisW/3); fill(255, 0, 0); box(2, 6, 12); pop();

    // Wheels (front pair steers with this.steerAngle)
    fill(30); specularMaterial(50); shininess(5); 
    let wb = chassisL*0.32, tw = chassisW*0.52;
    push(); translate(wb, chassisH/2, -tw); rotateY(-this.steerAngle); rotateX(HALF_PI); cylinder(12, 10, 6, 1); pop();
    push(); translate(wb, chassisH/2, tw); rotateY(-this.steerAngle); rotateX(HALF_PI); cylinder(12, 10, 6, 1); pop();
    push(); translate(-wb, chassisH/2, -tw); rotateX(HALF_PI); cylinder(12, 10, 6, 1); pop();
    push(); translate(-wb, chassisH/2, tw); rotateX(HALF_PI); cylinder(12, 10, 6, 1); pop();
    pop();
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

calculation Grip vs Drift Traction Blend this.vel.lerp(desiredVel, currentTraction);

Blends the car's actual velocity toward the direction it's facing at a rate controlled by traction - low traction while steering fast means the car slides sideways instead of instantly correcting, which is the entire drift effect

conditional Drift Detection this.isDrifting = (slipAmount > 2.5 && speed > 5);

Flags the car as 'drifting' whenever its actual velocity diverges enough from its facing direction while moving fast enough, driving skidmarks, smoke, sound, and cash rewards

for-loop Obstacle Bounce Check for (let obs of obstacles) {

Checks every obstacle for a close-range collision and applies a bounce-away velocity plus damage if it's the player

conditional Armor Tier Body Kit Stack if (stats.armorTier >= 2) {

Each armor tier from 2 to 10 adds another visual upgrade (side skirts, roll cage, spoiler, bumpers, exhausts, roof lights) stacked directly on top of the base chassis

this.steerAngle = lerp(this.steerAngle, steerInput * PI / 4, 0.2);
Smoothly animates the visible front-wheel turn angle toward the input steering direction rather than snapping instantly
this.vel.add(p5.Vector.mult(forward, gas * this.accel)); this.vel.mult(this.friction);
Applies acceleration in the facing direction, then applies friction to gradually slow the car down every frame
turnEffect *= map(speed, 0, this.maxSpeed, 1.2, 0.5); this.angle += turnEffect;
Cars turn more sharply at low speed and less sharply at top speed, mimicking real vehicle handling
if (abs(steerInput) > 0 && speed > 15) currentTraction = this.driftGrip;
Switches to a much lower traction value while actively steering at speed, which is what allows the car to slide instead of gripping the road
let slipAmount = p5.Vector.dist(this.vel, desiredVel);
Measures how far the car's actual velocity has diverged from where it 'should' be pointing - a large gap means the car is sliding, i.e. drifting
if (this.isPlayer) { cash += 2; updateCashUI(); if (wantedStars === 0) triggerWanted(); }
Only the human player earns cash for drifting, and their first drift also alerts the police
takeDamage(amount) { ... let dmgMultiplier = (this.isPlayer) ? max(0.15, 1.1 - (activeArmor * 0.09)) : 1;
Higher armor tiers reduce incoming damage, but it's clamped at a minimum of 15% so the car is never fully invincible
generateSkidmarks() { ... let rl = p5.Vector.sub(rearCenter, p5.Vector.mult(right, 22)); let rr = p5.Vector.add(rearCenter, p5.Vector.mult(right, 22));
Calculates the left and right rear wheel positions relative to the car's rotation, so skidmarks appear exactly where the tires are, not just at the car's center
checkObstacleCollisions() { for (let obs of obstacles) { if (distSq(...) < 1600) {
1600 is 40 squared, meaning a collision triggers whenever the car center comes within 40 units of an obstacle
let stats = this.customStats || playerStats;
Cop cars and NPCs use their own hardcoded stats, but the player's visible upgrades (engine scoop size, armor add-ons) read from the shared playerStats object unless overridden

windowResized()

windowResized() is a built-in p5.js callback that fires automatically whenever the browser window changes size - essential for any full-window responsive sketch.

function windowResized() { resizeCanvas(windowWidth, windowHeight); perspective(PI / 1.8, width / height, 10, 50000); }
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the canvas to match the browser window whenever it changes size (e.g. rotating a phone)
perspective(PI / 1.8, width / height, 10, 50000);
Recalculates the 3D perspective's aspect ratio so the scene doesn't look stretched after resizing

showScreen()

This function is the traffic controller between p5's canvas and the surrounding HTML UI - all menu navigation flows through it to guarantee a consistent single-screen-visible state.

function showScreen(id) {
  document.querySelectorAll('.screen, #ui-layer').forEach(el => el.style.display = 'none');
  let tgt = document.getElementById(id); if (tgt) tgt.style.display = (id === 'ui-layer') ? 'block' : 'flex';
}
Line-by-line explanation (2 lines)
document.querySelectorAll('.screen, #ui-layer').forEach(el => el.style.display = 'none');
Hides every menu/HUD overlay in the page before showing the requested one, ensuring only one screen is ever visible at a time
tgt.style.display = (id === 'ui-layer') ? 'block' : 'flex';
The in-game HUD uses block layout while menu screens use flex layout for their centered menu boxes

setupMenuInteractions()

This function is essentially the game's event-binding hub - every clickable button in every menu screen is connected to game logic here, keeping all the DOM wiring in one predictable place instead of scattered inline HTML handlers.

function setupMenuInteractions() {
  let bindClick = (id, fn) => { let el = document.getElementById(id); if (el) el.onclick = fn; };
  
  bindClick('btn-play-options', () => { gameState = 'MODE_SELECT'; showScreen('mode-screen'); });
  bindClick('btn-mode-back', () => { gameState = 'HOME'; showScreen('home-screen'); });
  
  bindClick('btn-start', () => { userStartAudio().then(() => sfx.start()); initGameWorld('NORMAL'); gameState = 'PLAYING'; showScreen('ui-layer'); });
  bindClick('btn-coop', () => { userStartAudio().then(() => sfx.start()); initGameWorld('COOP'); gameState = 'PLAYING'; showScreen('ui-layer'); });
  bindClick('btn-copnado', () => { userStartAudio().then(() => sfx.start()); initGameWorld('COPNADO'); gameState = 'PLAYING'; showScreen('ui-layer'); });
  
  bindClick('btn-shop', () => { gameState = 'SHOP'; updateShopUI(); showScreen('shop-screen'); });
  bindClick('btn-credits', () => { gameState = 'CREDITS'; showScreen('credits-screen'); });
  bindClick('btn-back', () => { gameState = 'HOME'; showScreen('home-screen'); });
  bindClick('btn-shop-back', () => { gameState = 'HOME'; showScreen('home-screen'); });
  bindClick('btn-restart', () => { gameState = 'HOME'; showScreen('home-screen'); });
  bindClick('btn-skip-cutscene', () => { skipCutscene(); });
  
  bindClick('buy-speed', () => buyUpgrade('speed', 1000));
  bindClick('buy-armor', () => buyUpgrade('armor', 1000));
  bindClick('buy-sportscar', () => buyCar('Sports Car', [0, 100, 255], 38, 0.8, 5000));
  bindClick('buy-hypercar', () => buyCar('Hypercar', [255, 200, 0], 45, 1.1, 15000));
  
  bindClick('buy-endcar', () => buyCar('The End Car', [255, 0, 85], 55, 1.5, 100000));
  
  bindClick('buy-custom', () => {
    if (customUnlocked) {
      gameState = 'CUSTOMIZER'; previewCar = new Car(0, 0, color(playerStats.color)); previewCar.isPlayer = true; updatePreviewCar(); showScreen('custom-screen');
    } else if (cash >= 20000) { cash -= 20000; customUnlocked = true; updateShopUI(); updateCashUI(); }
  });
  bindClick('btn-custom-back', () => { gameState = 'SHOP'; updateShopUI(); showScreen('shop-screen'); });
  bindClick('btn-custom-apply', () => {
    playerStats.color = [parseInt(document.getElementById('c-red').value), parseInt(document.getElementById('c-green').value), parseInt(document.getElementById('c-blue').value)];
    playerStats.engineLevel = parseInt(document.getElementById('c-eng').value); playerStats.armorTier = parseInt(document.getElementById('c-arm').value);
    playerStats.maxSpeed = 35 + ((playerStats.engineLevel - 1) * 2); playerStats.accel = 0.6 + ((playerStats.engineLevel - 1) * 0.05);
    gameState = 'SHOP'; updateShopUI(); showScreen('shop-screen');
  });

  bindClick('btn-admin-submit', () => {
    if (document.getElementById('admin-pass').value === '182015') { gameState = 'ADMIN_PANEL'; showScreen('admin-panel-screen'); } 
    else { document.getElementById('admin-pass').value = ''; alert('INVALID PIN'); }
  });
  bindClick('btn-admin-cancel', () => { gameState = 'HOME'; showScreen('home-screen'); });
  bindClick('btn-admin-close', () => { gameState = 'HOME'; showScreen('home-screen'); });
  
  bindClick('btn-cheat-money', () => { cash += 9999999; updateCashUI(); updateShopUI(); alert('ADDED $9,999,999'); });
  bindClick('btn-cheat-max', () => {
    playerStats.engineLevel = 15; playerStats.armorTier = 10; playerStats.maxSpeed = 35 + (14 * 2); playerStats.accel = 0.6 + (14 * 0.05);
    alert('ALL UPGRADES MAXED OUT');
  });
  bindClick('btn-cheat-cops', () => {
    gameState = 'PLAYING'; userStartAudio().then(() => sfx.start()); showScreen('ui-layer');
    wantedStars = 5; chaseFrames = 99999; updateWantedUI(); for(let i=0; i<10; i++) spawnCop();
  });
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation bindClick Helper let bindClick = (id, fn) => { let el = document.getElementById(id); if (el) el.onclick = fn; };

A tiny reusable helper that safely attaches an onclick handler only if the element actually exists in the HTML

conditional Game Mode Start Buttons bindClick('btn-start', () => { userStartAudio().then(() => sfx.start()); initGameWorld('NORMAL'); gameState = 'PLAYING'; showScreen('ui-layer'); });

Starts the browser's audio context (required by browsers before any sound can play), builds a fresh game world, and switches to the PLAYING state

conditional Admin PIN Check if (document.getElementById('admin-pass').value === '182015') { gameState = 'ADMIN_PANEL'; showScreen('admin-panel-screen'); }

Compares the typed password against a hardcoded PIN to unlock the cheat panel

userStartAudio().then(() => sfx.start());
Browsers block audio until a user interaction occurs; this call resumes the audio context right when the player clicks Play, then starts all the oscillators
bindClick('buy-speed', () => buyUpgrade('speed', 1000));
Wires the Engine Upgrade shop button to spend 1000 cash on the next engine level
playerStats.maxSpeed = 35 + ((playerStats.engineLevel - 1) * 2); playerStats.accel = 0.6 + ((playerStats.engineLevel - 1) * 0.05);
Recalculates the player's actual speed/acceleration stats from their chosen engine level whenever the custom builder is applied

updatePreviewCar()

This function is called every time a customizer slider fires its 'input' event, giving the player instant visual feedback on their car build before committing to it.

function updatePreviewCar() {
  if (!previewCar) return;
  previewCar.color = color(parseInt(document.getElementById('c-red').value), parseInt(document.getElementById('c-green').value), parseInt(document.getElementById('c-blue').value));
  previewCar.customStats = { engineLevel: parseInt(document.getElementById('c-eng').value), armorTier: parseInt(document.getElementById('c-arm').value) };
}
Line-by-line explanation (2 lines)
if (!previewCar) return;
Guards against running before the preview car object exists, e.g. before the customizer screen has been opened
previewCar.customStats = { engineLevel: parseInt(...), armorTier: parseInt(...) };
Live-updates the preview car's stats object so its 3D model instantly reflects slider changes like engine scoop size and armor tier

triggerShowroomCutscene()

This is called after every successful purchase, giving upgrades a satisfying reveal moment instead of silently updating a stat number.

function triggerShowroomCutscene() {
  gameState = 'CUTSCENE_BUY_CAR'; cutsceneTimer = millis();
  document.querySelectorAll('.screen, #ui-layer').forEach(el => el.style.display = 'none');
  document.getElementById('btn-skip-cutscene').style.display = 'block'; 
  cutsceneData.car = new Car(0, 0, color(playerStats.color)); cutsceneData.car.isPlayer = true; 
}
Line-by-line explanation (2 lines)
cutsceneTimer = millis();
Records the exact moment the cutscene starts so draw() can later calculate elapsed time with millis() - cutsceneTimer
cutsceneData.car = new Car(0, 0, color(playerStats.color)); cutsceneData.car.isPlayer = true;
Spawns a fresh showcase copy of the player's newly upgraded car to display spinning in the showroom

buyUpgrade()

Both upgrade types share the same purchase gate pattern (check cash, check cap, deduct, apply, celebrate) - a reusable structure worth recognizing for any in-game shop system.

function buyUpgrade(type, cost) {
  if (cash >= cost) {
    if (type === 'speed' && playerStats.engineLevel < 15) { cash -= cost; playerStats.engineLevel++; playerStats.maxSpeed += 2; playerStats.accel += 0.05; triggerShowroomCutscene(); }
    else if (type === 'armor' && playerStats.armorTier < 10) { cash -= cost; playerStats.armorTier++; triggerShowroomCutscene(); }
    updateShopUI(); updateCashUI();
  }
}
Line-by-line explanation (2 lines)
if (cash >= cost) {
Guards the whole purchase behind an affordability check
if (type === 'speed' && playerStats.engineLevel < 15) {
Speed upgrades are capped at engine level 15 - buying beyond that does nothing even if you have enough cash

buyCar()

buyCar() is a generic function reused for every purchasable vehicle in the shop, with each button passing its own name/color/stats/cost - avoiding four nearly-identical functions.

function buyCar(name, col, speed, acc, cost) {
  if (cash >= cost) {
    cash -= cost; playerStats.color = col; 
    playerStats.maxSpeed = speed + ((playerStats.engineLevel - 1) * 2); playerStats.accel = acc + ((playerStats.engineLevel - 1) * 0.05);
    updateShopUI(); updateCashUI(); triggerShowroomCutscene();
  }
}
Line-by-line explanation (1 lines)
playerStats.maxSpeed = speed + ((playerStats.engineLevel - 1) * 2);
New cars have their own base speed, but the player's existing engine upgrade levels still add a bonus on top, so previous progress isn't wasted

updateShopUI()

Refreshing button labels from live game state (rather than hardcoding them in HTML) keeps the shop UI honest about the player's actual progress.

function updateShopUI() {
  let scDisp = document.getElementById('shop-cash-display'); if (scDisp) scDisp.innerText = cash;
  let bSpd = document.getElementById('buy-speed');
  if (bSpd) { bSpd.innerText = (playerStats.engineLevel < 15) ? `Engine Upgrade ($1000) [Lvl ${playerStats.engineLevel}]` : `Engine Upgrade (MAXED) [Lvl 15]`; }
  let bArm = document.getElementById('buy-armor');
  if (bArm) { bArm.innerText = (playerStats.armorTier < 10) ? `Armor Plating ($1000) [Tier ${playerStats.armorTier}/10]` : `Armor Plating (MAXED) [Tier 10]`; }
  let bCustom = document.getElementById('buy-custom');
  if (bCustom) { bCustom.innerText = customUnlocked ? `Enter Custom Builder` : `Unlock Custom Builder ($20000)`; }
}
Line-by-line explanation (1 lines)
bSpd.innerText = (playerStats.engineLevel < 15) ? `Engine Upgrade ($1000) [Lvl ${playerStats.engineLevel}]` : `Engine Upgrade (MAXED) [Lvl 15]`;
Uses a template literal to show the current engine level directly on the button text, and swaps to a 'MAXED' label once level 15 is reached

triggerGameOver()

triggerGameOver() prepares all the data the CUTSCENE_BUSTED branch of draw() needs (which cop, where, whose fault) before handing off to that scripted sequence.

function triggerGameOver(title, description) {
  bustedOnFoot = (gameState === 'ON_FOOT'); gameState = 'CUTSCENE_BUSTED'; cutsceneTimer = millis(); exitPointerLock();
  document.querySelectorAll('.screen, #ui-layer').forEach(el => el.style.display = 'none');
  
  if (gameMode !== 'COPNADO') document.getElementById('btn-skip-cutscene').style.display = 'block';
  
  let bTitle = document.getElementById('busted-title'); if (bTitle) bTitle.innerText = title;
  let bDesc = document.getElementById('busted-desc'); if (bDesc) bDesc.innerText = description;
  
  if (!bustedOnFoot && car.health <= 0) {
    let nearestCop = cops[0]; let minDist = Infinity;
    for(let c of cops) { let d = distSq(car.pos.x, car.pos.y, c.pos.x, c.pos.y); if(d < minDist) { minDist = d; nearestCop = c; } }
    if(!nearestCop || minDist > 200000) {
        let spawnAng = car.angle + PI + random(-0.5, 0.5);
        nearestCop = new Car(car.pos.x + cos(spawnAng)*250, car.pos.y + sin(spawnAng)*250, color(255));
        nearestCop.isCop = true; nearestCop.copType = 'COP'; cops.push(nearestCop);
    }
    cutsceneData.copActorPos = createVector(nearestCop.pos.x, nearestCop.pos.y); cutsceneData.playerActorPos = createVector(car.pos.x, car.pos.y);
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Fallback Cop Spawn if(!nearestCop || minDist > 200000) {

If no cop is close enough (or none exist), spawns a fresh one right behind the player so the arrest cutscene always has an actor

bustedOnFoot = (gameState === 'ON_FOOT');
Remembers whether the player was caught while driving or while walking, since the cutscene plays out differently for each
document.querySelectorAll('.screen, #ui-layer').forEach(el => el.style.display = 'none');
Hides all UI so the cutscene camera and 3D scene are unobstructed

setupMobileControls()

This bridges touchscreen buttons into the same touchInput object that draw() already checks alongside keyIsDown(), meaning the core driving code never needs to know whether input came from a keyboard or a touchscreen.

function setupMobileControls() {
  const bindBtn = (id, key) => {
    let el = document.getElementById(id); if (!el) return;
    el.addEventListener('touchstart', (e) => { e.preventDefault(); touchInput[key] = true; }, { passive: false });
    el.addEventListener('touchend', (e) => { e.preventDefault(); touchInput[key] = false; }, { passive: false });
    el.addEventListener('mousedown', (e) => { e.preventDefault(); touchInput[key] = true; });
    el.addEventListener('mouseup', (e) => { e.preventDefault(); touchInput[key] = false; });
    el.addEventListener('mouseleave', (e) => { e.preventDefault(); touchInput[key] = false; });
  };
  bindBtn('btn-left', 'left'); bindBtn('btn-right', 'right'); bindBtn('btn-gas', 'up'); bindBtn('btn-brake', 'down');
  let exitBtn = document.getElementById('btn-exit');
  if (exitBtn) {
    exitBtn.addEventListener('touchstart', (e) => { e.preventDefault(); toggleVehicle(); });
    exitBtn.addEventListener('mousedown', (e) => { e.preventDefault(); toggleVehicle(); });
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

calculation bindBtn Helper const bindBtn = (id, key) => {

A reusable helper that wires both touch and mouse events to a shared touchInput object, so the same button works on mobile and desktop

el.addEventListener('touchstart', (e) => { e.preventDefault(); touchInput[key] = true; }, { passive: false });
Sets a flag to true the moment a mobile control button is pressed down, and preventDefault stops the browser from scrolling/zooming on touch
el.addEventListener('mouseleave', (e) => { e.preventDefault(); touchInput[key] = false; });
Releases the control if the mouse drags off the button while held, preventing it from getting 'stuck on'

📦 Key Variables

gameState string

The master state machine value controlling which menu, cutscene, or gameplay mode is currently active

let gameState = 'HOME';
gameMode string

Which gameplay variant is active: NORMAL, COOP, or COPNADO, affecting car stats, camera, and weather

let gameMode = 'NORMAL';
car object

The main player's Car instance, driven, rendered, and tracked by the camera every frame

let car;
car2 object

The second player's Car instance, only created in COOP mode

let car2;
camPos object

A p5.Vector storing the smoothed chase-camera position that lerps toward the car each frame

let camPos;
obstacles array

Stores all the glowing cone obstacle objects scattered around the infinite world

let obstacles = [];
skidmarks array

Stores recent tire-mark positions drawn as flat dark squares on the ground

let skidmarks = [];
smokeParticles array

Stores every active smoke puff object with its own position, velocity, and fading life

let smokeParticles = [];
tornado object

The Tornado entity used only in Copnado mode to chase the player and pull in cops

let tornado;
cash number

The player's earned currency, spent on shop upgrades and new cars

let cash = 0;
customUnlocked boolean

Whether the player has purchased access to the custom car builder screen

let customUnlocked = false;
previewCar object

A temporary Car instance shown spinning in the custom builder screen so players preview color/upgrade choices live

let previewCar = null;
playerStats object

Stores the player's persistent upgrade progress: engine level, top speed, acceleration, armor tier, and car color

let playerStats = { engineLevel: 1, maxSpeed: 35, accel: 0.6, armorTier: 1, color: [220, 30, 30] };
cutsceneTimer number

Records the millis() timestamp a cutscene started, used to calculate elapsed cutscene time

let cutsceneTimer = 0;
cutsceneData object

A scratch object holding whatever extra data the current cutscene needs, like actor positions or a showcase car

let cutsceneData = {};
bustedOnFoot boolean

Whether the player was busted while walking (true) or driving (false), changing how the arrest cutscene plays

let bustedOnFoot = false;
playerPos object

The first-person player's 3D position while walking around ON_FOOT

let playerPos;
playerYaw number

The player's left/right look rotation angle in first-person mode

let playerYaw = 0;
playerPitch number

The player's up/down look rotation angle in first-person mode, clamped to avoid flipping

let playerPitch = 0;
npcs array

Stores all wandering traffic Car instances that aren't controlled by the player or police

let npcs = [];
cops array

Stores every active police Car instance currently chasing the player

let cops = [];
wantedStars number

The current 0-5 wanted level, driving how many and how tough the spawned cops are

let wantedStars = 0;
chaseFrames number

Counts how many frames the player has been wanted, used to escalate the star level over time

let chaseFrames = 0;
sfx object

The single SoundManager instance handling all synthesized engine, skid, crash, and music audio

let sfx;
touchInput object

Tracks which virtual mobile control buttons are currently pressed, mirrored into the same checks as keyIsDown()

let touchInput = { up: false, down: false, left: false, right: false };
lastSpeedText string

Caches the last speed HUD text so the DOM is only updated when the displayed text actually changes

let lastSpeedText = "";
lastCash number

Caches the last displayed cash value to avoid redundant DOM updates every frame

let lastCash = -1;
lastHealth number

Caches the last displayed health percentage to avoid redundant DOM updates every frame

let lastHealth = -1;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG drawObstacles()

checkPos is built as createVector(car.pos.x, car.pos.y, car.pos.y) - the z-argument reuses car.pos.y instead of car.pos.x/y properly mapping to the obstacle's x/z fields, which happens to work only because distSq() is called with matching (x,y) pairs, but it's confusing and fragile if the vector's z were ever used directly.

💡 Use createVector(car.pos.x, car.pos.y) as a plain 2D vector, or clearly name a {x, z} plain object instead of borrowing p5.Vector's 3-component shape for 2D math.

PERFORMANCE checkObstacleCollisions() and drawObstacles()

Every car (player, both cops, and every NPC) loops through the entire obstacles array every single frame to check collisions, and drawObstacles() does another full pass for culling - with many NPCs/cops active this becomes O(n*m) per frame.

💡 Partition obstacles into a spatial grid keyed by rounded position (similar to the ground-plane snapping trick) so only nearby obstacles are checked, rather than testing distance to all 60+.

STYLE Car.display()

The display() method is extremely long, mixing five different cop-type models and ten armor-tier upgrade stages inline with magic numbers for every offset and size.

💡 Extract each cop type and each armor tier stage into its own small helper function (e.g. drawSwatBody(), drawArmorTier3()) to make the method readable and make individual upgrades easier to tweak in isolation.

BUG keyPressed()

The admin panel PIN ('182015') is hardcoded directly in client-side JavaScript, meaning anyone can view the page source and unlock infinite cash and max upgrades instantly.

💡 For a real deployed game, cheats/admin panels like this should be removed entirely or gated server-side rather than shipped in the client bundle.

FEATURE Global state / SoundManager

There's no way to save progress (cash, playerStats, customUnlocked) between page reloads - closing the tab loses everything.

💡 Add localStorage.setItem/getItem calls in updateCashUI() and after upgrades to persist playerStats and cash, restoring them in setup().

🔄 Code Flow

Code flow showing setup, initgameworld, draw, tornado, drawground, drawobstacles, drawskidmarks, drawsmoke, drawhuman, skipcutscene, processwantedescalation, updatewantedui, spawncop, triggerwanted, soundmanager, updatespeedtext, updatehealthui, updatecashui, distsq, updateplayerfps, mousepressed, keypressed, togglevehicle, handlecrash, spawnnpc, updatenpcs, updatecops, checkcollisions, car, windowresized, showscreen, setupmenuinteractions, updatepreviewcar, triggershowroomcutscene, buyupgrade, buycar, updateshopui, triggergameover, setupmobilecontrols

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

graph TD start[Start] --> setup[setup] setup --> initgameworld[initGameWorld] setup --> draw[draw loop] click setup href "#fn-setup" click initgameworld href "#fn-initgameworld" click draw href "#fn-draw" draw --> custom-slider-binding[Custom Slider Binding] draw --> cutscene-screens[Cutscene Screens] draw --> playing-branch[Playing Branch] draw --> onfoot-branch[On-Foot Branch] draw --> busted-branch[Busted Branch] draw --> infinite-obstacles[Infinite Obstacles] draw --> lighting-setup[Lighting Setup] draw --> checkcollisions[checkCollisions] draw --> updatespeedtext[updateSpeedText] draw --> updatehealthui[updateHealthUI] draw --> updatecashui[updateCashUI] draw --> updatewantedui[updateWantedUI] draw --> drawground[drawGround] draw --> drawobstacles[drawObstacles] draw --> drawskidmarks[drawSkidmarks] draw --> drawsmoke[drawSmoke] draw --> drawhuman[drawHuman] draw --> soundmanager[SoundManager] draw --> spawncop[spawnCop] draw --> triggerwanted[triggerWanted] draw --> processwantedescalation[processWantedEscalation] draw --> mousepressed[mousePressed] draw --> keypressed[keyPressed] draw --> togglevehicle[toggleVehicle] draw --> windowresized[windowResized] draw --> showscreen[showScreen] draw --> setupmenuinteractions[setupMenuInteractions] draw --> updateshopui[updateShopUI] draw --> triggergameover[triggerGameOver] draw --> setupmobilecontrols[setupMobileControls] click custom-slider-binding href "#sub-custom-slider-binding" click cutscene-screens href "#sub-cutscene-screens" click playing-branch href "#sub-playing-branch" click onfoot-branch href "#sub-onfoot-branch" click busted-branch href "#sub-busted-branch" click infinite-obstacles href "#sub-infinite-obstacles" click lighting-setup href "#sub-lighting-setup" click checkcollisions href "#fn-checkcollisions" click updatespeedtext href "#fn-updateSpeedText" click updatehealthui href "#fn-updateHealthUI" click updatecashui href "#fn-updateCashUI" click updatewantedui href "#fn-updateWantedUI" click drawground href "#fn-drawGround" click drawobstacles href "#fn-drawObstacles" click drawskidmarks href "#fn-drawSkidmarks" click drawsmoke href "#fn-drawSmoke" click drawhuman href "#fn-drawHuman" click soundmanager href "#fn-SoundManager" click spawncop href "#fn-spawnCop" click triggerwanted href "#fn-triggerWanted" click processwantedescalation href "#fn-processWantedEscalation" click mousepressed href "#fn-mousePressed" click keypressed href "#fn-keyPressed" click togglevehicle href "#fn-toggleVehicle" click windowresized href "#fn-windowResized" click showscreen href "#fn-showScreen" click setupmenuinteractions href "#fn-setupMenuInteractions" click updateshopui href "#fn-updateShopUI" click triggergameover href "#fn-triggerGameOver" click setupmobilecontrols href "#fn-setupMobileControls" playing-branch --> wasd-movement[WASD Movement] playing-branch --> drift-detection[Drift Detection] playing-branch --> drift-physics[Drift Physics] playing-branch --> exit-car[Exit Vehicle] playing-branch --> enter-car[Re-enter Vehicle] playing-branch --> npc-collision-loop[NPC Collision Loop] playing-branch --> cop-collision-loop[Cop Collision Loop] playing-branch --> npc-recycle[NPC Recycling] playing-branch --> npc-ai-steer[NPC AI Steer] playing-branch --> angle-wrap[Angle Wrap] playing-branch --> obstacle-bounce[Obstacle Bounce Check] playing-branch --> updateplayerfps[updatePlayerFPS] playing-branch --> updatespeedtext[updateSpeedText] click wasd-movement href "#sub-wasd-movement" click drift-detection href "#sub-drift-detection" click drift-physics href "#sub-drift-physics" click exit-car href "#sub-exit-car" click enter-car href "#sub-enter-car" click npc-collision-loop href "#sub-npc-collision-loop" click cop-collision-loop href "#sub-cop-collision-loop" click npc-recycle href "#sub-npc-recycle" click npc-ai-steer href "#sub-npc-ai-steer" click angle-wrap href "#sub-angle-wrap" click obstacle-bounce href "#sub-obstacle-bounce" click updateplayerfps href "#sub-updateplayerfps" onfoot-branch --> mouse-look[Mouse Look] onfoot-branch --> togglevehicle[toggleVehicle] click mouse-look href "#sub-mouse-look" click togglevehicle href "#sub-togglevehicle" busted-branch --> nearest-cop-search[Nearest Cop Search] busted-branch --> fallback-cop-spawn[Fallback Cop Spawn] click nearest-cop-search href "#sub-nearest-cop-search" click fallback-cop-spawn href "#sub-fallback-cop-spawn" infinite-obstacles --> obstacle-spawn-loop[Obstacle Spawn Loop] infinite-obstacles --> obstacle-culling[Obstacle Culling] click obstacle-spawn-loop href "#sub-obstacle-spawn-loop" click obstacle-culling href "#sub-obstacle-culling" lighting-setup --> copnado-override[Copnado Mode Overrides] lighting-setup --> coop-override[Co-Op Setup] click copnado-override href "#sub-copnado-override" click coop-override href "#sub-coop-override"

❓ Frequently Asked Questions

What visual experience does the DRIFT STARS sketch provide?

The DRIFT STARS sketch creates a stylized 3D cityscape where players can navigate through glowing obstacles and dynamic environments, leaving skidmarks and smoke trails behind their custom cars.

How can users interact with the DRIFT STARS sketch?

Users can interact by controlling their car, switching game modes, upgrading vehicles, and exploring the city on foot or by driving, making it a dynamic and immersive experience.

What creative coding concepts are showcased in the DRIFT STARS sketch?

This sketch demonstrates advanced techniques in 3D rendering, physics simulation for vehicle movement, and interactive game mechanics to enhance user engagement.

Preview

DRIFT STARS (FINAL RELEASE) - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of DRIFT STARS (FINAL RELEASE) - Code flow showing setup, initgameworld, draw, tornado, drawground, drawobstacles, drawskidmarks, drawsmoke, drawhuman, skipcutscene, processwantedescalation, updatewantedui, spawncop, triggerwanted, soundmanager, updatespeedtext, updatehealthui, updatecashui, distsq, updateplayerfps, mousepressed, keypressed, togglevehicle, handlecrash, spawnnpc, updatenpcs, updatecops, checkcollisions, car, windowresized, showscreen, setupmenuinteractions, updatepreviewcar, triggershowroomcutscene, buyupgrade, buycar, updateshopui, triggergameover, setupmobilecontrols
Code Flow Diagram