Night CityThird-Person Blueprint

Concrete implementation patterns for a GTA-style on-foot city game in three.js: player movement, orbit-follow camera, procedural city blocks, AI, wanted stars, hitscan shooting, neon atmosphere, HUD, minimap, and performance strategy.

WASD + Mouse Camera Hitscan Weapons Wanted AI Wet Neon Roads Instancing + LOD
★★★☆☆
SMG30 / 180

1. Third-Person Character Controller

For a GTA-style on-foot controller, keep gameplay movement separate from rendering. Use a simple capsule approximation for collision, a velocity vector, gravity, grounded checks, and a yaw-only player orientation. The camera should orbit with mouse input, then follow a spring-smoothed target behind the player.

Recommended structure

const player = {
  root: new THREE.Group(),
  position: new THREE.Vector3(0, 1, 0),
  velocity: new THREE.Vector3(),
  radius: 0.38,
  height: 1.75,
  speedWalk: 4.5,
  speedSprint: 7.5,
  jumpSpeed: 6.2,
  grounded: false,
  yaw: 0
};

const cameraRig = {
  yaw: 0,
  pitch: -0.25,
  distance: 5.0,
  shoulder: new THREE.Vector3(0.45, 1.45, 0),
  lookHeight: 1.35,
  currentPos: new THREE.Vector3(),
  currentLook: new THREE.Vector3()
};

function updatePlayer(dt, keys, camera) {
  const forward = new THREE.Vector3();
  camera.getWorldDirection(forward);
  forward.y = 0;
  forward.normalize();

  const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();

  const input = new THREE.Vector3();
  if (keys.KeyW) input.add(forward);
  if (keys.KeyS) input.sub(forward);
  if (keys.KeyD) input.add(right);
  if (keys.KeyA) input.sub(right);

  if (input.lengthSq() > 0.0001) {
    input.normalize();
    player.yaw = Math.atan2(input.x, input.z);
    const speed = keys.ShiftLeft ? player.speedSprint : player.speedWalk;
    player.velocity.x = THREE.MathUtils.damp(player.velocity.x, input.x * speed, 12, dt);
    player.velocity.z = THREE.MathUtils.damp(player.velocity.z, input.z * speed, 12, dt);
  } else {
    player.velocity.x = THREE.MathUtils.damp(player.velocity.x, 0, 10, dt);
    player.velocity.z = THREE.MathUtils.damp(player.velocity.z, 0, 10, dt);
  }

  if (keys.Space && player.grounded) {
    player.velocity.y = player.jumpSpeed;
    player.grounded = false;
  }

  player.velocity.y -= 18.0 * dt;
  player.position.addScaledVector(player.velocity, dt);

  resolveWorldCollision(player);

  player.root.position.copy(player.position);
  player.root.rotation.y = THREE.MathUtils.dampAngle(player.root.rotation.y, player.yaw, 14, dt);
}

Grounding and simple collision

In a city game, many objects are axis-aligned: roads, sidewalks, building boxes, cars. Start with cheap collision volumes before adding a full physics engine.

const colliders = []; // Box3 objects for buildings, parked cars, props.

function resolveWorldCollision(p) {
  // Ground plane / sidewalks.
  const groundY = 0;
  if (p.position.y < groundY + p.height * 0.5) {
    p.position.y = groundY + p.height * 0.5;
    p.velocity.y = Math.max(0, p.velocity.y);
    p.grounded = true;
  }

  // Capsule simplified as horizontal circle against boxes.
  const feet = new THREE.Vector3(p.position.x, p.position.y - p.height * 0.5, p.position.z);
  const center = new THREE.Vector3(p.position.x, p.position.y, p.position.z);

  for (const box of colliders) {
    const yOverlaps = center.y + p.height * 0.5 > box.min.y && feet.y < box.max.y;
    if (!yOverlaps) continue;

    const closest = new THREE.Vector3(
      THREE.MathUtils.clamp(p.position.x, box.min.x, box.max.x),
      p.position.y,
      THREE.MathUtils.clamp(p.position.z, box.min.z, box.max.z)
    );

    const delta = new THREE.Vector3().subVectors(p.position, closest);
    delta.y = 0;
    const distSq = delta.lengthSq();

    if (distSq < p.radius * p.radius && distSq > 0.00001) {
      const dist = Math.sqrt(distSq);
      const push = (p.radius - dist);
      delta.multiplyScalar(1 / dist);
      p.position.addScaledVector(delta, push);
      p.velocity.x -= delta.x * Math.min(0, p.velocity.dot(delta));
      p.velocity.z -= delta.z * Math.min(0, p.velocity.dot(delta));
    }
  }
}

Smooth orbit-follow camera with wall avoidance

Capture pointer lock on click. Mouse changes camera yaw and pitch. Compute an ideal camera position, then raycast from player target to ideal camera position. If blocked, place the camera slightly in front of the hit point.

const cameraRaycaster = new THREE.Raycaster();
const cameraBlockers = []; // building meshes, large props, cars

function onMouseMove(e) {
  if (document.pointerLockElement !== document.body) return;
  cameraRig.yaw -= e.movementX * 0.0022;
  cameraRig.pitch -= e.movementY * 0.0018;
  cameraRig.pitch = THREE.MathUtils.clamp(cameraRig.pitch, -0.85, 0.35);
}

function updateCamera(dt, camera) {
  const target = player.position.clone().add(new THREE.Vector3(0, cameraRig.lookHeight, 0));

  const qYaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), cameraRig.yaw);
  const qPitch = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), cameraRig.pitch);
  const offset = new THREE.Vector3(0, 0, cameraRig.distance).applyQuaternion(qPitch).applyQuaternion(qYaw);
  const shoulder = cameraRig.shoulder.clone().applyQuaternion(qYaw);

  let ideal = target.clone().add(shoulder).add(offset);

  const rayDir = new THREE.Vector3().subVectors(ideal, target);
  const dist = rayDir.length();
  rayDir.normalize();

  cameraRaycaster.set(target, rayDir);
  cameraRaycaster.far = dist;
  const hits = cameraRaycaster.intersectObjects(cameraBlockers, false);
  if (hits.length) {
    ideal = hits[0].point.addScaledVector(rayDir, -0.25);
  }

  cameraRig.currentPos.lerp(ideal, 1 - Math.exp(-12 * dt));
  cameraRig.currentLook.lerp(target, 1 - Math.exp(-16 * dt));

  camera.position.copy(cameraRig.currentPos);
  camera.lookAt(cameraRig.currentLook);
}
Best practice: make the camera feel expensive. Use damping everywhere, a slight shoulder offset, collision pull-in, field-of-view changes during sprint, and small recoil impulses while shooting.

2. Efficient Procedural City Generation

Generate a grid of city blocks, then separate the world into render layers: roads, sidewalks, buildings, signs, lamps, props, and navigation data. For performance, use shared geometries, merged meshes, and instancing. Avoid hundreds of unique building meshes.

Block layout

function mulberry32(seed) {
  return function() {
    seed |= 0;
    seed = seed + 0x6D2B79F5 | 0;
    let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

const city = {
  blockSize: 48,
  roadWidth: 14,
  sidewalkWidth: 4,
  blocksX: 10,
  blocksZ: 10,
  rng: mulberry32(1337),
  colliders: [],
  sidewalkNodes: []
};

function generateCity(scene) {
  createRoadGrid(scene);
  createSidewalks(scene);
  createBuildingInstances(scene);
  createStreetProps(scene);
  createSidewalkGraph();
}

Instanced buildings

Use THREE.InstancedMesh for building bodies. Vary scale, height, color, roughness, and emissive windows. For more detail, add a second instanced window facade mesh or use a shader/material map.

const bodyGeo = new THREE.BoxGeometry(1, 1, 1);
const bodyMat = new THREE.MeshStandardMaterial({
  color: 0x151b2e,
  roughness: 0.82,
  metalness: 0.12
});

const buildings = new THREE.InstancedMesh(bodyGeo, bodyMat, 800);
buildings.castShadow = true;
buildings.receiveShadow = true;

const matrix = new THREE.Matrix4();
const color = new THREE.Color();

let id = 0;
for (let gx = -city.blocksX; gx <= city.blocksX; gx++) {
  for (let gz = -city.blocksZ; gz <= city.blocksZ; gz++) {
    const blockCenterX = gx * city.blockSize;
    const blockCenterZ = gz * city.blockSize;

    const count = 2 + Math.floor(city.rng() * 4);
    for (let i = 0; i < count; i++) {
      const w = 10 + city.rng() * 18;
      const d = 10 + city.rng() * 18;
      const h = 12 + Math.pow(city.rng(), 2.2) * 80;

      const x = blockCenterX + (city.rng() - 0.5) * 24;
      const z = blockCenterZ + (city.rng() - 0.5) * 24;

      matrix.compose(
        new THREE.Vector3(x, h * 0.5, z),
        new THREE.Quaternion(),
        new THREE.Vector3(w, h, d)
      );

      buildings.setMatrixAt(id, matrix);
      color.setHSL(0.62 + city.rng() * 0.08, 0.18, 0.12 + city.rng() * 0.08);
      buildings.setColorAt(id, color);

      city.colliders.push(new THREE.Box3(
        new THREE.Vector3(x - w * 0.5, 0, z - d * 0.5),
        new THREE.Vector3(x + w * 0.5, h, z + d * 0.5)
      ));

      id++;
    }
  }
}

buildings.count = id;
buildings.instanceMatrix.needsUpdate = true;
if (buildings.instanceColor) buildings.instanceColor.needsUpdate = true;
scene.add(buildings);

Road and sidewalk mesh strategy

Build roads as large planes or merged boxes. Use material repeats via texture.wrapS = texture.wrapT = THREE.RepeatWrapping. For wet roads, use a dark MeshStandardMaterial with high metalness and low roughness, plus neon reflection decals.

const roadMat = new THREE.MeshStandardMaterial({
  color: 0x05070b,
  roughness: 0.18,
  metalness: 0.55
});

const roadGeo = new THREE.BoxGeometry(city.roadWidth, 0.04, city.blockSize * city.blocksZ * 2.4);
const roadXGeo = new THREE.BoxGeometry(city.blockSize * city.blocksX * 2.4, 0.04, city.roadWidth);

for (let i = -city.blocksX; i <= city.blocksX; i++) {
  const road = new THREE.Mesh(roadGeo, roadMat);
  road.position.set(i * city.blockSize, 0.01, 0);
  road.receiveShadow = true;
  scene.add(road);
}

for (let j = -city.blocksZ; j <= city.blocksZ; j++) {
  const road = new THREE.Mesh(roadXGeo, roadMat);
  road.position.set(0, 0.012, j * city.blockSize);
  road.receiveShadow = true;
  scene.add(road);
}

3. Pedestrian and Police AI with Steering Behaviors

Keep AI cheap. Use finite-state machines plus simple steering: seek, flee, arrive, separation, and line-of-sight. Do not pathfind every frame. Use sidewalk graph nodes and recompute routes on a timer.

Pedestrian states

class NPC {
  constructor(mesh, type) {
    this.mesh = mesh;
    this.type = type; // "ped" or "police"
    this.pos = mesh.position;
    this.vel = new THREE.Vector3();
    this.maxSpeed = type === "police" ? 5.2 : 2.2;
    this.state = "wander";
    this.health = 100;
    this.targetNode = null;
    this.threat = null;
    this.thinkTimer = Math.random();
    this.shootCooldown = 0;
  }
}

function steerSeek(npc, target, strength = 1) {
  const desired = new THREE.Vector3().subVectors(target, npc.pos);
  desired.y = 0;
  const dist = desired.length();
  if (dist < 0.001) return new THREE.Vector3();
  desired.normalize().multiplyScalar(npc.maxSpeed);
  return desired.sub(npc.vel).multiplyScalar(strength);
}

function steerFlee(npc, threatPos, panicRadius = 28) {
  const away = new THREE.Vector3().subVectors(npc.pos, threatPos);
  away.y = 0;
  const dist = away.length();
  if (dist > panicRadius || dist < 0.001) return new THREE.Vector3();
  away.normalize().multiplyScalar(npc.maxSpeed * 1.7);
  return away.sub(npc.vel).multiplyScalar(1.4);
}

function updatePed(npc, dt, player) {
  npc.thinkTimer -= dt;

  if (npc.state !== "downed") {
    const sawThreat = wanted.stars > 0 && npc.pos.distanceTo(player.position) < 30;
    if (sawThreat) {
      npc.state = "flee";
      npc.threat = player.position;
      npc.maxSpeed = 5.8;
    }
  }

  let accel = new THREE.Vector3();

  if (npc.state === "wander") {
    if (!npc.targetNode || npc.pos.distanceTo(npc.targetNode) < 1.2) {
      npc.targetNode = pickRandomSidewalkNodeNear(npc.pos, 60);
    }
    accel.add(steerSeek(npc, npc.targetNode, 0.8));
  }

  if (npc.state === "flee") {
    accel.add(steerFlee(npc, npc.threat, 80));
    accel.add(randomPanicJitter(npc, dt));
  }

  accel.add(avoidObstacles(npc));
  accel.add(separation(npc, nearbyNPCs(npc), 1.8));

  npc.vel.addScaledVector(accel, dt);
  npc.vel.clampLength(0, npc.maxSpeed);
  npc.pos.addScaledVector(npc.vel, dt);

  faceVelocity(npc.mesh, npc.vel, dt);
}

Police chase and shoot

Police should feel purposeful but remain simple. Spawn them near roads at wanted level thresholds. They seek player, maintain distance, use line-of-sight raycasts, and fire at intervals. If close to a car, they can use it as cover by selecting cover points.

function updatePolice(cop, dt, player) {
  const toPlayer = new THREE.Vector3().subVectors(player.position, cop.pos);
  const dist = toPlayer.length();

  cop.state = dist > 18 ? "chase" : "engage";

  let accel = new THREE.Vector3();

  if (cop.state === "chase") {
    accel.add(steerSeek(cop, player.position, 1.2));
  }

  if (cop.state === "engage") {
    const desiredRange = 14;
    if (dist < desiredRange - 3) {
      accel.add(steerFlee(cop, player.position, 25).multiplyScalar(0.7));
    } else if (dist > desiredRange + 4) {
      accel.add(steerSeek(cop, player.position, 0.8));
    }

    const cover = findCoverPointNear(cop.pos, player.position);
    if (cover && cop.health < 60) {
      accel.add(steerSeek(cop, cover, 1.1));
    }

    cop.shootCooldown -= dt;
    if (cop.shootCooldown <= 0 && hasLineOfSight(cop.pos, player.position)) {
      policeShoot(cop, player);
      cop.shootCooldown = 0.7 + Math.random() * 0.6;
    }
  }

  accel.add(avoidObstacles(cop));
  cop.vel.addScaledVector(accel, dt);
  cop.vel.clampLength(0, cop.maxSpeed);
  cop.pos.addScaledVector(cop.vel, dt);
  faceVelocity(cop.mesh, cop.vel, dt);
}

function hasLineOfSight(a, b) {
  const origin = a.clone().add(new THREE.Vector3(0, 1.5, 0));
  const target = b.clone().add(new THREE.Vector3(0, 1.3, 0));
  const dir = target.sub(origin);
  const dist = dir.length();
  dir.normalize();

  raycaster.set(origin, dir);
  raycaster.far = dist;
  return raycaster.intersectObjects(lineOfSightBlockers, false).length === 0;
}

4. Shooting: Raycasting, Muzzle Flash, Tracers, Hit Detection

For pistols and SMGs, use hitscan raycasts. The weapon fires from a muzzle socket, but aim from camera center. To avoid shots going through nearby walls, raycast from camera to choose an aim point, then raycast from muzzle to that aim point.

Weapon data

const weapons = {
  fists:  { type: "melee", damage: 18, range: 1.5, cooldown: 0.42 },
  pistol: { type: "gun", damage: 34, range: 90, cooldown: 0.22, spread: 0.006, ammo: 12, reserve: 72 },
  smg:    { type: "gun", damage: 16, range: 70, cooldown: 0.075, spread: 0.018, ammo: 30, reserve: 180 }
};

let currentWeapon = "pistol";
let fireCooldown = 0;

function updateWeapon(dt, input) {
  fireCooldown -= dt;
  if (input.fire && fireCooldown <= 0) {
    const w = weapons[currentWeapon];
    if (w.type === "gun" && w.ammo > 0) {
      fireGun(w);
      w.ammo--;
      fireCooldown = w.cooldown;
    }
  }
}

Camera-centered hitscan

const raycaster = new THREE.Raycaster();
const shootables = []; // NPC meshes, vehicle meshes, props
const worldHitObjects = []; // buildings, cars, props, NPC hit proxies

function fireGun(w) {
  const camOrigin = camera.position.clone();
  const camDir = new THREE.Vector3();
  camera.getWorldDirection(camDir);

  applySpread(camDir, w.spread);

  raycaster.set(camOrigin, camDir);
  raycaster.far = w.range;
  const cameraHits = raycaster.intersectObjects(worldHitObjects, true);
  const aimPoint = cameraHits.length
    ? cameraHits[0].point.clone()
    : camOrigin.clone().addScaledVector(camDir, w.range);

  const muzzlePos = muzzleSocket.getWorldPosition(new THREE.Vector3());
  const muzzleDir = aimPoint.clone().sub(muzzlePos).normalize();

  raycaster.set(muzzlePos, muzzleDir);
  raycaster.far = w.range;
  const hits = raycaster.intersectObjects(worldHitObjects, true);

  const end = hits.length ? hits[0].point.clone() : muzzlePos.clone().addScaledVector(muzzleDir, w.range);

  spawnMuzzleFlash(muzzlePos, muzzleDir);
  spawnTracer(muzzlePos, end);
  spawnGunSmoke(muzzlePos);
  cameraRecoil(w);

  if (hits.length) {
    const hit = hits[0];
    spawnImpactFX(hit.point, hit.face ? hit.face.normal : new THREE.Vector3(0, 1, 0));
    const npc = findNPCFromObject(hit.object);
    if (npc) {
      damageNPC(npc, w.damage, hit.point);
      registerCrime("assaultWithWeapon", npc.pos);
    }
  }
}

function applySpread(dir, spread) {
  dir.x += (Math.random() - 0.5) * spread;
  dir.y += (Math.random() - 0.5) * spread;
  dir.z += (Math.random() - 0.5) * spread;
  dir.normalize();
}

Visual effects

const tracerMat = new THREE.LineBasicMaterial({
  color: 0xffd27a,
  transparent: true,
  opacity: 0.95,
  blending: THREE.AdditiveBlending
});

const transientFX = [];

function spawnTracer(start, end) {
  const geo = new THREE.BufferGeometry().setFromPoints([start, end]);
  const line = new THREE.Line(geo, tracerMat.clone());
  line.userData.life = 0.06;
  scene.add(line);
  transientFX.push(line);
}

function spawnMuzzleFlash(pos, dir) {
  const sprite = new THREE.Sprite(muzzleFlashMaterial.clone());
  sprite.position.copy(pos).addScaledVector(dir, 0.12);
  sprite.scale.setScalar(0.45 + Math.random() * 0.25);
  sprite.material.rotation = Math.random() * Math.PI;
  sprite.userData.life = 0.035;
  scene.add(sprite);
  transientFX.push(sprite);
}

function updateFX(dt) {
  for (let i = transientFX.length - 1; i >= 0; i--) {
    const fx = transientFX[i];
    fx.userData.life -= dt;
    if (fx.material) fx.material.opacity = Math.max(0, fx.userData.life / 0.08);
    if (fx.userData.life <= 0) {
      scene.remove(fx);
      if (fx.geometry) fx.geometry.dispose();
      if (fx.material) fx.material.dispose();
      transientFX.splice(i, 1);
    }
  }
}

5. Wanted Level / Star System Logic

Use an internal wanted score that decays over time. Stars are a presentation layer derived from score. Crimes add heat only if witnessed, heard, captured by line-of-sight, or committed near police.

1 starFleeing pedestrians, police investigate/chase on foot.
2 starsMore officers spawn, pistol fire, wider search radius.
3 starsSMG police, roadblocks, faster spawn cadence.
4–5 starsHeavy units, helicopters optional, aggressive tactics.
const wanted = {
  score: 0,
  stars: 0,
  visibleTimer: 0,
  lastKnownPosition: new THREE.Vector3(),
  searchRadius: 0,
  decayDelay: 8,
  timeSinceCrime: 999,
  copSpawnTimer: 0
};

const crimeHeat = {
  gunshot: 8,
  assault: 25,
  assaultWithWeapon: 45,
  killPed: 90,
  shootPolice: 120
};

function registerCrime(type, position) {
  const witnessed = isCrimeWitnessed(position) || type === "gunshot";
  if (!witnessed) return;

  wanted.score += crimeHeat[type] || 10;
  wanted.score = Math.min(wanted.score, 500);
  wanted.lastKnownPosition.copy(position);
  wanted.timeSinceCrime = 0;
  wanted.visibleTimer = 5;
}

function updateWanted(dt, player) {
  wanted.timeSinceCrime += dt;

  if (wanted.timeSinceCrime > wanted.decayDelay && !policeCanSeePlayer(player)) {
    wanted.score = Math.max(0, wanted.score - dt * 8);
  }

  wanted.stars =
    wanted.score > 360 ? 5 :
    wanted.score > 250 ? 4 :
    wanted.score > 150 ? 3 :
    wanted.score > 60  ? 2 :
    wanted.score > 10  ? 1 : 0;

  wanted.searchRadius = 25 + wanted.stars * 35;

  wanted.copSpawnTimer -= dt;
  if (wanted.stars > 0 && wanted.copSpawnTimer <= 0) {
    spawnPoliceWave(wanted.stars, player.position);
    wanted.copSpawnTimer = Math.max(2.5, 8 - wanted.stars);
  }

  if (policeCanSeePlayer(player)) {
    wanted.lastKnownPosition.copy(player.position);
    wanted.timeSinceCrime = Math.min(wanted.timeSinceCrime, 2);
  }
}

Crime witnessing

function isCrimeWitnessed(pos) {
  for (const npc of npcs) {
    if (npc.state === "downed") continue;
    const d = npc.pos.distanceTo(pos);
    if (d < 12) return true;
    if (d < 45 && hasLineOfSight(npc.pos, pos)) return true;
  }

  for (const cop of cops) {
    if (cop.pos.distanceTo(pos) < 80 && hasLineOfSight(cop.pos, pos)) {
      return true;
    }
  }

  return false;
}

6. Night-Time Atmosphere

Your visual identity should carry the experience: blue-black fog, neon magenta/cyan signs, warm street lamps, reflective wet asphalt, volumetric-looking particles, bloom-like glow, and strong silhouettes.

Renderer, color management, fog

const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

scene.fog = new THREE.FogExp2(0x06101f, 0.018);

const hemi = new THREE.HemisphereLight(0x294cff, 0x050205, 0.8);
scene.add(hemi);

const moon = new THREE.DirectionalLight(0x9eb8ff, 1.2);
moon.position.set(-30, 60, 20);
moon.castShadow = true;
moon.shadow.mapSize.set(2048, 2048);
scene.add(moon);

Neon signs

Use emissive materials for signs. Do not use a real point light for every sign. Add a small number of actual lights near hero locations; fake the rest with emissive materials, billboards, and reflected colored planes on the road.

const neonMat = new THREE.MeshStandardMaterial({
  color: 0x101020,
  emissive: 0xff22cc,
  emissiveIntensity: 4.5,
  roughness: 0.35
});

const sign = new THREE.Mesh(new THREE.BoxGeometry(5, 1.2, 0.12), neonMat);
sign.position.set(12, 8, -20);
scene.add(sign);

// Optional real light only for important signs.
const neonLight = new THREE.PointLight(0xff33dd, 2.5, 12, 2);
neonLight.position.copy(sign.position);
scene.add(neonLight);

Fake wet-road reflections

const reflectionMat = new THREE.MeshBasicMaterial({
  color: 0xff35d1,
  transparent: true,
  opacity: 0.22,
  depthWrite: false,
  blending: THREE.AdditiveBlending
});

const reflection = new THREE.Mesh(new THREE.PlaneGeometry(3, 12), reflectionMat);
reflection.rotation.x = -Math.PI / 2;
reflection.position.set(12, 0.025, -20);
scene.add(reflection);

Bloom-like glow

The best option is postprocessing with UnrealBloomPass, but if you want a single-file minimal dependency build, fake it with additive billboards, emissive strips, and transparent glow meshes. If you do add postprocessing:

import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));

const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.8, 0.55, 0.85);
composer.addPass(bloom);

// Render with composer.render() instead of renderer.render(scene, camera).

7. HUD and Minimap

Use DOM or 2D canvas for the HUD because it is flexible, crisp, cheap, and easy to style. Use a second canvas for the minimap. Render roads and icons from gameplay data rather than rendering a whole second 3D scene unless you need full 3D radar.

HUD DOM pattern

<div id="hud">
  <div id="stars"></div>
  <div class="bar health"><i></i></div>
  <div class="bar armor"><i></i></div>
  <div id="ammo">Pistol 12 / 72</div>
  <canvas id="minimap" width="180" height="180"></canvas>
</div>

function updateHUD() {
  starsEl.textContent = "★★★★★".slice(0, wanted.stars) + "☆☆☆☆☆".slice(0, 5 - wanted.stars);
  healthFill.style.width = player.health + "%";
  armorFill.style.width = player.armor + "%";

  const w = weapons[currentWeapon];
  ammoEl.textContent = currentWeapon.toUpperCase() + " " + (w.ammo ?? "--") + " / " + (w.reserve ?? "--");
}

Canvas minimap

function drawMinimap(ctx, player, npcs, cops) {
  const size = ctx.canvas.width;
  const scale = 2.5; // world units per pixel
  ctx.clearRect(0, 0, size, size);

  ctx.fillStyle = "rgba(3, 10, 22, .86)";
  ctx.fillRect(0, 0, size, size);

  ctx.save();
  ctx.translate(size / 2, size / 2);
  ctx.rotate(player.root.rotation.y);

  // roads
  ctx.strokeStyle = "rgba(120, 230, 255, .25)";
  ctx.lineWidth = 5;
  for (const road of roadSegments) {
    ctx.beginPath();
    ctx.moveTo((road.a.x - player.position.x) / scale, (road.a.z - player.position.z) / scale);
    ctx.lineTo((road.b.x - player.position.x) / scale, (road.b.z - player.position.z) / scale);
    ctx.stroke();
  }

  function dot(pos, color, r) {
    const x = (pos.x - player.position.x) / scale;
    const y = (pos.z - player.position.z) / scale;
    if (Math.abs(x) > size / 2 || Math.abs(y) > size / 2) return;
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }

  for (const npc of npcs) dot(npc.pos, "#67ffba", 2);
  for (const cop of cops) dot(cop.pos, "#ff4b6e", 3);

  ctx.restore();

  // player arrow fixed in center
  ctx.fillStyle = "#ffffff";
  ctx.beginPath();
  ctx.moveTo(size / 2, size / 2 - 8);
  ctx.lineTo(size / 2 - 6, size / 2 + 7);
  ctx.lineTo(size / 2 + 6, size / 2 + 7);
  ctx.closePath();
  ctx.fill();
}

8. Performance Tips for Many Lights and NPCs

A GTA-style city can become expensive quickly. Budget your frame: render cost, animation cost, AI cost, raycasts, postprocessing, and shadows.

GeometryUse InstancedMesh for buildings, lamps, traffic cones, trash cans, signs, and simple NPC crowds.
LightsOnly a handful of real shadow-casting lights. Most neon should be emissive textures and fake glow sprites.
AIUpdate distant NPCs at 2–5 Hz. Full steering only within 60–90 meters of player.
RaycastsUse layers, broadphase distance checks, and simplified invisible hit boxes.

Concrete optimizations

class SpatialHash {
  constructor(cellSize = 16) {
    this.cellSize = cellSize;
    this.map = new Map();
  }

  key(x, z) {
    return Math.floor(x / this.cellSize) + "," + Math.floor(z / this.cellSize);
  }

  clear() {
    this.map.clear();
  }

  insert(obj, pos) {
    const k = this.key(pos.x, pos.z);
    let bucket = this.map.get(k);
    if (!bucket) this.map.set(k, bucket = []);
    bucket.push(obj);
  }

  query(pos, radius) {
    const out = [];
    const r = Math.ceil(radius / this.cellSize);
    const cx = Math.floor(pos.x / this.cellSize);
    const cz = Math.floor(pos.z / this.cellSize);

    for (let x = cx - r; x <= cx + r; x++) {
      for (let z = cz - r; z <= cz + r; z++) {
        const bucket = this.map.get(x + "," + z);
        if (bucket) out.push(...bucket);
      }
    }
    return out;
  }
}

function updateNPCScheduler(dt) {
  for (const npc of npcs) {
    const d = npc.pos.distanceTo(player.position);
    if (d < 50) updateNPC(npc, dt);
    else {
      npc.lowFreqTimer -= dt;
      if (npc.lowFreqTimer <= 0) {
        updateNPC(npc, 0.25);
        npc.lowFreqTimer = 0.25 + Math.random() * 0.25;
      }
    }
  }
}
Production rule: every feature needs a distance budget. Near the player: full lighting, animation, AI, collisions. Mid distance: simplified AI and lower LOD. Far distance: static silhouettes, emissive windows, and no per-frame logic.
### ~google/gemini-pro-latest CYBER-CITY REDUX
HP
AP
★ ★ ★ ★ ★
FISTS
INF / INF

CYBER-CITY REDUX

WASD: Move | Mouse: Look/Aim | Click: Shoot | 1, 2, 3: Weapons
Shift: Sprint | Space: Jump | C: Crouch/Take Soft Cover

Survive the neon streets. Peds will flee. Cops will hunt you if you break the law.

## Analysis **Consensus** - Use THREE.InstancedMesh for buildings and repeated props to collapse hundreds of draw calls into one — the single biggest city-generation performance win. - Cap the number of real point lights (~6-8) and pool/reposition them near the player; fake most neon with emissive/MeshBasicMaterial rather than real lights, since forward-rendered WebGL degrades past ~8-16 lights. - Camera-relative movement: derive forward/right from camera yaw, apply WASD, normalize, and always scale by clamped delta time (THREE.Clock) to stay frame-rate independent. - Use THREE.Raycaster for hitscan shooting fired from camera center to match the crosshair, with per-weapon spread jitter and nearest-hit selection so tracers stop at walls. - Tracers as short-lived THREE.Line segments and muzzle flash as additive sprite (+ optional brief PointLight), with proper geometry/material disposal to avoid leaks. - Wanted level backed by a float/score that decays over time after a grace delay, with integer stars derived from it and cop spawn count scaling with stars. - Night atmosphere via FogExp2, ACESFilmic tone mapping, sRGB output, a single shadow-casting directional 'moon' light, and emissive neon; wet roads faked with low-roughness/high-metalness MeshStandardMaterial. - UnrealBloomPass via EffectComposer/RenderPass is the recommended way to make neon and emissive windows glow. - HUD should be DOM/CSS and the minimap a 2D canvas rather than a second three.js camera, since drawing the 3D scene twice is wasteful. - Simple state-machine + steering-behavior AI (wander/flee for peds, chase/shoot for cops) is sufficient; avoid per-frame pathfinding and full physics engines. - Use cheap collision volumes (AABB/Box3 or grid cells) collected during generation rather than a physics engine or scene-graph raycasts. - Cap renderer.setPixelRatio to ~1.5-2 to bound fill cost on high-DPI displays. **Contradictions** - **Collision representation for world/player** - ~anthropic/claude-opus-latest: Flat AABB arrays with capsule-vs-box slide resolution and raycaster.ray.intersectBox for shooting/camera. - ~openai/gpt-latest: Box3 colliders with horizontal-circle-vs-box closest-point push-out (capsule approximation). - ~google/gemini-pro-latest: A 2D integer grid array (0=road,1=building) giving O(1) axis-separated collision checks — simpler but tied to a rigid uniform grid and cannot represent buildings smaller/larger than a cell. - **Wet-road reflection technique** - ~anthropic/claude-opus-latest: WebGLCubeRenderTarget + CubeCamera feeding envMap, throttled to ~4Hz for real reflections. - ~openai/gpt-latest: No real reflection — low-roughness/high-metalness material plus additive transparent colored planes under signs to fake neon smear; reserve real planar reflection for hero puddles. - ~google/gemini-pro-latest: Only a low-roughness/high-metalness material with bloom; no explicit reflection geometry at all. - **Whether a working full game or a blueprint/showcase was delivered** - ~anthropic/claude-opus-latest: Claims a complete playable single-file game (neon_city.html) with all 8 systems wired, describes it but the actual file/code is not shown inline. - ~openai/gpt-latest: Delivers a documentation-style HTML page with code snippets plus a decorative non-interactive three.js background animation (auto-moving player, timed tracers) — not a playable game. - ~google/gemini-pro-latest: Delivers a genuinely interactive, self-contained playable game with pointer-lock, real input, AI, wanted system and HUD in one file. - **Camera collision method** - ~anthropic/claude-opus-latest: Raycast focus→desired-camera position against building AABBs, pull camera in on hit. - ~openai/gpt-latest: THREE.Raycaster.intersectObjects against blocker meshes, place camera just in front of hit. - ~google/gemini-pro-latest: Manual fixed-step march along the back-ray sampling the 2D grid every 0.5 units — cheaper but coarse and can miss thin geometry. - **Frame-rate-independent smoothing idiom** - ~anthropic/claude-opus-latest: 1 - Math.pow(k, dt) exponential smoothing. - ~openai/gpt-latest: THREE.MathUtils.damp/dampAngle and 1 - Math.exp(-k*dt). - ~google/gemini-pro-latest: Does not use frame-rate-independent smoothing; snaps rotation/camera instantly, only clamps dt. **Partial coverage** - ~anthropic/claude-opus-latest, ~openai/gpt-latest: Two-stage hitscan or the subtlety of firing from muzzle vs aiming from camera center (GPT explicitly raycasts camera→aim-point then muzzle→aim-point to avoid shots through nearby walls). - ~anthropic/claude-opus-latest, ~openai/gpt-latest: Baked CanvasTexture emissive window facades as a cheap way to get thousands of lit windows at night with zero per-light cost. - ~openai/gpt-latest: Deterministic seeded PRNG (mulberry32) for repeatable city generation and a spatial hash grid for O(1) neighbor queries. - ~openai/gpt-latest, ~google/gemini-pro-latest: Crime-witnessing / line-of-sight gating: wanted heat only accrues if a ped or cop actually sees/hears the crime, not unconditionally. - ~openai/gpt-latest: Distance-based AI update scheduling (full steering within ~60-90m, low-frequency 2-5Hz updates for distant NPCs) and object pooling for tracers/flashes/decals. - ~anthropic/claude-opus-latest, ~openai/gpt-latest: Keeping the moon's shadow frustum tight and following the player for high-res shadows without covering the whole city. - ~openai/gpt-latest: Explicit per-star escalation design table (1 star investigate, 2 pistols, 3 SMG/roadblocks, 4-5 heavy units/helicopters). - ~google/gemini-pro-latest: Concrete damage pipeline: armor absorbs before health, crouch/soft-cover halves incoming damage, and a DOM damage-flash + WASTED/PAUSED state screens. - ~openai/gpt-latest, ~google/gemini-pro-latest: BufferGeometryUtils.mergeGeometries for merging static far geometry (roads, curbs, sidewalks) named as a distinct optimization from instancing. - ~anthropic/claude-opus-latest: Explicit gamma-correction pass ordering (keep GammaCorrectionShader last after ACES + bloom to avoid washout). **Unique insights** - ~anthropic/claude-opus-latest: Throttling the CubeCamera reflection capture to ~4Hz and hiding the player during capture — a concrete cost/quality tradeoff for real-time-ish wet-road reflections rarely mentioned. - ~openai/gpt-latest: The 'build a vertical slice first' production discipline (one district, one weapon, one star, ten peds, two cops, polished camera) before scaling — the only response addressing project methodology over pure code. - ~google/gemini-pro-latest: Actually shipping a functionally interactive game with a nested Player→YawPivot→PitchPivot→Camera object hierarchy for mouse-look, demonstrating the controller working rather than only describing it. - ~openai/gpt-latest: Explicit 'make the camera feel expensive' design note — damping everywhere, shoulder offset, FOV shift on sprint, recoil impulses — treating game feel as a first-class engineering concern. - ~anthropic/claude-opus-latest: Claim of a verification workflow (syntax-check, headless render, NaN check on lamp math) as part of delivering the artifact, signaling QA of generated code. **Blind spots** - WebGPURenderer / TSL and the fact that classic forward-rendering light limits are being superseded; none mention modern deferred-ish or clustered lighting alternatives or that three.js is moving toward WebGPU. - Character animation: all three use static capsules/boxes with no mention of skeletal animation, AnimationMixer, glTF rigged characters, or blending walk/run/aim states — a huge gap for a 'GTA-style' game. - Audio entirely absent — gunfire, footsteps, sirens, ambient city noise and positional audio (THREE.PositionalAudio) are core to the genre and unmentioned. - Gemini's rigid uniform grid cannot represent varied building footprints, alleys, or non-grid roads; none discuss navmesh/waypoint pathfinding so AI will clip corners and get stuck on real obstacles. - No discussion of memory management at scale (disposing geometries/textures on chunk unload, streaming/chunked world loading) for a city larger than the fixed grid. - Reflector/planar reflection depth-cost, SSR limitations, and that CubeCamera reflections are position-fixed (wrong parallax for a flat road) are glossed over — the 'fake' approaches have visible artifacts none flag honestly. - Input robustness: no mention of gamepad support, key-rebinding, or the pointer-lock UX pitfalls (Esc exiting lock, focus loss) beyond a basic pause. - Accessibility, mobile/touch controls, and performance profiling methodology (stats.js, spector.js, GPU frame budgeting) are not addressed. - Determinism/networking hooks and save state are ignored, and none note that Math.random-based spawning makes the world non-reproducible except GPT's seeded PRNG for geometry only. - Security/legal: 'GTA-style' branding and asset/IP considerations, plus CDN reliability (pinning three.js version, SRI) for a single-file distributable, are unmentioned. Now I'll build a polished, complete single-file game incorporating the best techniques from the research. ```html NEON PRECINCT
N
HEALTH100
ARMOR100
CLEAN
1FIST
2PISTOL
3SMG

NEON
PRECINCT

A NIGHT IN THE CITY
WASD Move  ·  MOUSE Look  ·  SHIFT Sprint  ·  SPACE Jump
1 / 2 / 3 Fists · Pistol · SMG  ·  CLICK Shoot  ·  C Crouch (cover)
Peds flee gunfire. Draw heat and the precinct comes hunting.
Press ESC to release mouse