﻿This conversation was generated with Duck.ai (https://duck.ai) using OpenAI's GPT-5 mini Model. AI chats may display inaccurate or offensive information (see https://duckduckgo.com/duckai/privacy-terms for more info).

====================

User prompt 1 of 6 - 3/19/2026, 10:55:27 PM:
Please write an HTML/JS first-person maze navigation game. The display should use `<canvas>` 2D and use standard WASD/mouselook controls; this will require capturing the mouse pointer to support mouselook, using an HTML5 API whose name I forget, as well as fullscreen mode. Do not use 3D `<canvas>` contexts; instead, for clarity, do the perspective projection math in JS and fill the relevant polygons to display the maze walls and floor using the painter's algorithm. Generate the maze with the union-find algorithm on a 10×10 grid, ensuring that there is exactly one path between any two points, and then remove another three walls at random in order to allow maze islands and multiple paths between certain points. Put ten treasures at random in the maze; the player wins the game when they successfully collect all ten treasures. Wall rectangles closer to the player should appear brighter than faraway rectangles.

Don't begin immediately on writing the code; instead, give me a high-level overview of how the code should work and what the design should be like so that I can give you feedback before you write code you might get attached to.

GPT-5 mini:
## High-level design overview

### Goal
Build a first-person, raycast-style maze explorer rendered to a 2D canvas by projecting 3D wall/floor polygons with perspective math done in JS. Controls: WASD for movement, pointer lock API for mouselook, and a fullscreen toggle. Maze: 10×10 generated with union-find (perfect maze) then remove 3 extra walls randomly. Place 10 treasures randomly in distinct corridor cells. Player collects treasures by coming within a radius. Walls closer appear brighter.

---

### Major components
1. HTML structure
   - Canvas element sized to the window.
   - Minimal UI overlay: treasure count and prompt to click to lock pointer / fullscreen / restart.

2. Input and browser APIs
   - Pointer lock API for mouselook (requestPointerLock / pointerlockchange events).
   - Keyboard events for WASD (keydown/keyup).
   - Fullscreen API (requestFullscreen / exitFullscreen) for optional toggle.
   - Mouse movement events used while pointer is locked to change player yaw/pitch.

3. Maze generation
   - Represent 10×10 grid of cells; each cell has four walls (N,E,S,W).
   - Use union-find (disjoint set) to create a perfect maze: randomly iterate walls between adjacent cells, remove a wall if cells are in different sets and union them, until all cells connected.
   - After creating the perfect maze, pick three remaining walls at random (i.e., walls that separate two cells and currently exist) and remove them to create multiple paths / islands.
   - Place player start: choose a random cell (or fixed e.g., [0,0]) and direction.
   - Place ten treasure items in distinct cells that are not the player start and are reachable (they will be, by construction).

4. World representation for rendering and collision
   - Each wall is represented as an axis-aligned segment in world coordinates: walls are line segments between cell corners at integer grid positions; wall height is constant (e.g., 1.8 units).
   - Floor plane at y=0; ceiling optional.
   - Doors/openings correspond to absent wall segments.

5. Player physics and collision
   - Player has position (x,z), height (eye), yaw (rotation around vertical axis), and pitch for mouselook (clamped).
   - Movement uses WASD relative to yaw; apply simple collision detection against wall bounding boxes expanded by player radius to prevent walking through walls.
   - Step size / speed controlled by delta time to be frame-rate independent.

6. Rendering pipeline (painter's algorithm, CPU projection)
   - Build an array of visible polygons per frame:
     - For each wall segment that exists, create a vertical rectangle polygon (4 vertices) in world 3D coords (x,y,z) — top and bottom corners at wall height/0.
     - Optionally generate floor polygons per cell (flat quads) for surface shading.
   - Transform vertices into camera (player) space by subtracting player position and rotating by negative yaw/pitch.
   - Apply perspective projection (simple pinhole camera): x_proj = (x_cam / z_cam) * focal, y_proj = (y_cam / z_cam) * focal; discard polygons with z_cam <= near plane.
   - Convert projected coordinates to canvas pixel coordinates.
   - Sort polygons back-to-front by average depth (z_cam) — painter's algorithm.
   - Draw polygons to canvas using 2D context fill — walls filled with color scaled by depth (closer = brighter); floor with gradient based on depth.
   - Draw HUD (treasure count) on top.

7. Treasure handling and display
   - Each treasure is associated with a cell center (x,z) and a small billboard or simple 2D circle sprite rendered as a vertical quad always facing the camera (or a simple projected square).
   - When player distance to treasure < pickup radius, mark as collected and increase count.
   - When count reaches 10, display a win overlay and stop movement.

8. Performance considerations
   - 10×10 maze yields a small number of wall polygons (max ~ (10+1)*10*2 ~ manageable); CPU projection each frame is fine.
   - Use requestAnimationFrame, compute dt, and avoid unnecessary allocations in the render loop (reuse arrays where possible).

9. UX details
   - Pointer lock instruction overlay explaining "click to play" and keylist (WASD, Space to jump optional, F to fullscreen).
   - Smooth mouse sensitivity and clamped pitch to avoid flipping.
   - Option to restart game and regenerate maze.

---

### Data structures (concise)
- Cell: {x, y, walls: {N,E,S,W} boolean}
- Wall segments: computed list of segments {x1,z1,x2,z2,exists}
- Treasure: {cellX, cellY, worldX, worldZ, collected}
- Player: {x, z, yaw, pitch, speed, radius, eyeHeight}
- DSU: parent[] for union-find

---

### Frame-by-frame sequence
1. Poll input (keyboard state and pointer movement deltas).
2. Update player orientation and position; apply collision clamping.
3. Recompute visible polygons (walls, floor cells, treasure billboards).
4. Transform, clip by near plane, project to 2D.
5. Sort polygons by depth and draw to canvas, shading by depth for brightness.
6. Draw HUD and overlays.
7. Check treasure collection and win condition.

---

### Edge cases & decisions I made (so you can adjust)
- Maze cell size: default 1.0 world unit per cell; wall thickness is infinitesimal as line segments but collision uses an expanded margin (player radius).
- Wall height: 1.8 units; eyeHeight: 1.6 units.
- Near plane: small positive value (e.g., 0.1) to avoid division by zero.
- Treasure rendering: simple billboard (a rectangle always facing camera) or just a projected circle on the floor; I assume billboard for visibility.
- Collision: simple axis-aligned bounding circle vs. wall segment distance test rather than full swept collision.
- Removing 3 extra walls: choose among interior separations that remain; ensure we don't create open outer boundary unless intended.

---

If you approve this design or want any modifications (maze size, movement feel, wall/floor colors, treasure appearance, level of physics fidelity, or starting location policy), tell me which items to change and I’ll write the complete HTML+JS implementation.

--------------------

User prompt 2 of 6 - 3/19/2026, 11:01:26 PM:
Mostly sounds great. I'd suggest using just a single floor polygon for the whole maze, or even just fill the canvas with grey or a gradient below the horizon before drawing walls, if that's simpler.  Also make sure the player radius is smaller than half the cell size (so the player fits between walls) but higher than the near plane (so the player can't see through walls by walking up to them).

GPT-5 mini:
Sounds good — I'll use a single floor (a full-canvas gradient under the horizon) and set player radius < 0.5 cell and > near plane. I'll proceed to write the full HTML+JS implementation with pointer lock, fullscreen, union-find maze (10×10) plus 3 extra removed walls, 10 treasures, painter's-algorithm projection, depth-based brightness, WASD movement, collision, and HUD. Any last tweaks before I start coding?

--------------------

User prompt 3 of 6 - 3/19/2026, 11:01:43 PM:
Go for it.

GPT-5 mini:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>First-Person Maze</title>
<style>
  html,body { height:100%; margin:0; background:#000; font-family:system-ui,Segoe UI,Roboto,Arial; color:#fff; }
  canvas { display:block; width:100vw; height:100vh; background:#000; }
  #overlay {
    position: absolute; left:0; top:0; right:0; padding:12px;
    display:flex; justify-content:space-between; align-items:center; pointer-events:none;
  }
  .panel { pointer-events:auto; background:rgba(0,0,0,0.35); padding:8px 12px; border-radius:6px; }
  #centerPrompt {
    position: absolute; left:50%; top:50%; transform:translate(-50%,-50%);
    background:rgba(0,0,0,0.6); padding:14px 18px; border-radius:8px; text-align:center;
  }
  button { margin-left:8px; padding:6px 10px; border-radius:5px; border:1px solid rgba(255,255,255,0.15); background:#222; color:#fff; cursor:pointer; }
  button:active { transform:translateY(1px); }
  #win {
    position:absolute; left:50%; top:40%; transform:translateX(-50%); background:rgba(0,0,0,0.75); padding:18px 22px; border-radius:8px; display:none;
  }
</style>
</head>
<body>
<canvas id="c"></canvas>

<div id="overlay">
  <div class="panel" id="leftPanel">
    <strong>Treasures:</strong> <span id="count">0</span>/10
  </div>
  <div class="panel" id="rightPanel">
    <small>WASD: move</small> • <small>Click: enable mouse look</small> • <small>F: fullscreen</small>
  </div>
</div>

<div id="centerPrompt">
  <div style="font-weight:600; margin-bottom:8px;">Click to play — enable mouse look</div>
  <div style="font-size:13px; color:#ddd;">Use WASD to move. Collect all 10 treasures.</div>
  <div style="margin-top:10px;">
    <button id="startBtn">Start</button>
    <button id="regenBtn">New Maze</button>
  </div>
</div>

<div id="win">
  <div style="font-size:20px; font-weight:700; margin-bottom:8px;">You collected all treasures!</div>
  <div style="margin-bottom:12px;"><button id="againBtn">Play Again</button></div>
</div>

<script>
(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

let W=innerWidth, H=innerHeight;
function resize(){ W=canvas.width = innerWidth; H=canvas.height = innerHeight; }
addEventListener('resize', resize);
resize();

/* Config */
const GRID = 10;
const CELL = 1.0;           // world units per cell
const WALL_H = 1.8;
const EYE_H = 1.6;
const NEAR = 0.08;
const FOV = 75 * Math.PI/180;
const FOCAL = 0.5 * H / Math.tan(FOV/2); // focal length in pixels
let PLAYER_RADIUS = 0.3;    // must be < 0.5 cell and > NEAR
if (PLAYER_RADIUS >= 0.5) PLAYER_RADIUS = 0.49;
if (PLAYER_RADIUS <= NEAR) PLAYER_RADIUS = NEAR + 0.01;

/* State */
let walls = []; // wall segments: {x1,z1,x2,z2,exists}
let treasures = []; // {x,z,collected}
let player = { x:0.5, z:0.5, yaw:0, pitch:0, speed:2.2, radius:PLAYER_RADIUS, eye:EYE_H };
let keys = {};
let pointerLocked = false;
let prevTime = performance.now();
let collected = 0;
const TARGET_TREASURES = 10;

/* Utility */
function randInt(n){ return Math.floor(Math.random()*n); }
function shuffle(arr){ for(let i=arr.length-1;i>0;i--){ const j=randInt(i+1); [arr[i],arr[j]]=[arr[j],arr[i]]; } }

/* Maze generation with union-find */
function generateMaze() {
  // cells indexed 0..GRID*GRID-1
  const parent = new Array(GRID*GRID).fill(-1);
  function find(a){ return parent[a]<0 ? a : parent[a]=find(parent[a]); }
  function union(a,b){
    a=find(a); b=find(b);
    if(a===b) return false;
    if(parent[a]>parent[b]) [a,b]=[b,a];
    parent[a]+=parent[b]; parent[b]=a;
    return true;
  }

  // Initialize all walls present between cells (internal walls)
  walls = [];
  // Vertical walls (between (x,y) and (x+1,y)) for x in [0..GRID-1], y in [0..GRID-1], we store only internal between cells
  // We'll store all possible separating walls with references to cell indices; also keep exterior border walls as always present.
  const wallList = []; // {a,b,x1,z1,x2,z2,exists}
  for(let y=0;y<GRID;y++){
    for(let x=0;x<GRID;x++){
      const i = y*GRID + x;
      // East wall: between (x,y) and (x+1,y) if x<GRID-1
      if(x<GRID-1){
        wallList.push({
          a: i,
          b: i+1,
          x1: (x+1)*CELL, z1: y*CELL,
          x2: (x+1)*CELL, z2: (y+1)*CELL,
          exists: true
        });
      }
      // South wall: between (x,y) and (x,y+1) if y<GRID-1
      if(y<GRID-1){
        wallList.push({
          a: i,
          b: i+GRID,
          x1: x*CELL, z1: (y+1)*CELL,
          x2: (x+1)*CELL, z2: (y+1)*CELL,
          exists: true
        });
      }
    }
  }

  // Kruskal-like random removal using union-find
  const order = Array.from({length:wallList.length}, (_,i)=>i);
  shuffle(order);
  for(const idx of order){
    const w = wallList[idx];
    if(union(w.a,w.b)){
      w.exists = false; // remove wall to connect cells
    } else {
      w.exists = true;
    }
  }

  // After perfect maze, remove 3 extra random existing walls (that separate cells) to create multiple paths
  const existingSeparators = wallList.filter(w=>w.exists);
  shuffle(existingSeparators);
  for(let i=0;i<3 && i<existingSeparators.length;i++){
    existingSeparators[i].exists = false;
  }

  // Build final walls array with outer border walls included
  walls = [];
  // Outer bounds
  // We'll create walls as segments that are present (including internal ones still marked exists)
  // Add vertical segments for grid lines with thickness ignored (segments along cell borders)
  // Start with internal separators
  for(const w of wallList){
    if(w.exists){
      walls.push({ x1:w.x1, z1:w.z1, x2:w.x2, z2:w.z2 });
    }
  }
  // Add outer rectangle walls (full border)
  // Top border z=0 from x=0..GRID
  walls.push({ x1:0, z1:0, x2:GRID*CELL, z2:0 });
  // Bottom border z=GRID
  walls.push({ x1:0, z1:GRID*CELL, x2:GRID*CELL, z2:GRID*CELL });
  // Left border x=0
  walls.push({ x1:0, z1:0, x2:0, z2:GRID*CELL });
  // Right border x=GRID
  walls.push({ x1:GRID*CELL, z1:0, x2:GRID*CELL, z2:GRID*CELL });

  // Now place treasures: pick distinct cell centers not equal to player start
  treasures = [];
  const cellIndices = Array.from({length:GRID*GRID}, (_,i)=>i);
  shuffle(cellIndices);
  // Player starting cell we'll put at a random cell
  const startIdx = cellIndices[0];
  const sx = (startIdx % GRID) + 0.5;
  const sy = Math.floor(startIdx / GRID) + 0.5;
  player.x = sx * CELL;
  player.z = sy * CELL;
  player.yaw = 0; player.pitch = 0;
  // Ensure we don't place treasures on start cell
  let placed=0, j=1;
  while(placed<TARGET_TREASURES && j<cellIndices.length){
    const idx = cellIndices[j++];
    const cx = (idx % GRID) + 0.5;
    const cz = Math.floor(idx/GRID) + 0.5;
    treasures.push({ x: cx*CELL, z: cz*CELL, collected:false });
    placed++;
  }
  collected = 0;
  updateCountHUD();
}

/* Input handling */
document.addEventListener('pointerlockchange', ()=> {
  pointerLocked = (document.pointerLockElement === canvas);
  centerPrompt.style.display = pointerLocked ? 'none' : '';
});
canvas.addEventListener('click', () => {
  canvas.requestPointerLock?.();
});
document.addEventListener('mousemove', (e) => {
  if(!pointerLocked) return;
  const sensitivity = 0.0026;
  player.yaw -= e.movementX * sensitivity;
  player.pitch -= e.movementY * sensitivity;
  const maxPitch = Math.PI/2 - 0.05;
  if(player.pitch > maxPitch) player.pitch = maxPitch;
  if(player.pitch < -maxPitch) player.pitch = -maxPitch;
});
document.addEventListener('keydown', e => {
  if(e.key === 'f' || e.key === 'F'){
    if(!document.fullscreenElement) document.documentElement.requestFullscreen?.();
    else document.exitFullscreen?.();
  }
  keys[e.key.toLowerCase()] = true;
});
document.addEventListener('keyup', e => {
  keys[e.key.toLowerCase()] = false;
});

/* Collision: distance from point to segment */
function pointSegDist(px,pz, x1,z1,x2,z2){
  const vx = x2-x1, vz = z2-z1;
  const wx = px-x1, wz = pz-z1;
  const c = (wx*vx + wz*vz) / (vx*vx + vz*vz);
  if(c<=0) {
    const dx = px-x1, dz = pz-z1;
    return Math.hypot(dx,dz);
  } else if(c>=1){
    const dx = px-x2, dz = pz-z2;
    return Math.hypot(dx,dz);
  } else {
    const projx = x1 + c*vx, projz = z1 + c*vz;
    return Math.hypot(px-projx, pz-projz);
  }
}

/* Update HUD */
const countEl = document.getElementById('count');
function updateCountHUD(){ countEl.textContent = collected; }

/* Rendering helpers: transform and project */
function worldToCamera(wx,wz, camX, camZ, yaw){
  // Translate relative to camera position and rotate by -yaw
  const rx = wx - camX;
  const rz = wz - camZ;
  const s = Math.sin(-yaw), c = Math.cos(-yaw);
  const x = rx*c - rz*s;
  const z = rx*s + rz*c;
  return { x,z };
}
function project(x,y,z){
  // camera space coordinates: assume camera looking along +z, y up
  const sx = (x * FOCAL) / z + W/2;
  const sy = ( -y * FOCAL) / z + H/2;
  return {sx,sy};
}

/* Render loop */
function render(now){
  const dt = Math.min(0.06, (now - prevTime)/1000);
  prevTime = now;
  update(dt);
  draw();
  requestAnimationFrame(render);
}

/* Update player movement and treasures */
function update(dt){
  // movement input
  let forward = 0, strafe = 0;
  if(keys['w']) forward += 1;
  if(keys['s']) forward -= 1;
  if(keys['a']) strafe -= 1;
  if(keys['d']) strafe += 1;
  // Normalize
  const len = Math.hypot(forward, strafe) || 1;
  forward /= len; strafe /= len;
  const moveSpeed = player.speed;
  const dx = (Math.cos(player.yaw)*forward + Math.cos(player.yaw + Math.PI/2)*strafe) * moveSpeed * dt;
  const dz = (Math.sin(player.yaw)*forward + Math.sin(player.yaw + Math.PI/2)*strafe) * moveSpeed * dt;

  // Attempt move with simple collision: sample final position and if colliding with any wall segment (distance < radius) revert axis-wise
  let nx = player.x + dx;
  let nz = player.z + dz;
  // Check X move
  let collideX = false;
  for(const w of walls){
    const d = pointSegDist(nx, player.z, w.x1, w.z1, w.x2, w.z2);
    if(d < player.radius){ collideX = true; break; }
  }
  if(collideX) nx = player.x;
  // Check Z move
  let collideZ = false;
  for(const w of walls){
    const d = pointSegDist(player.x, nz, w.x1, w.z1, w.x2, w.z2);
    if(d < player.radius){ collideZ = true; break; }
  }
  if(collideZ) nz = player.z;

  // Update
  player.x = nx; player.z = nz;

  // Check treasure pickup
  for(const t of treasures){
    if(t.collected) continue;
    const d = Math.hypot(player.x - t.x, player.z - t.z);
    if(d < 0.45){ t.collected = true; collected++; updateCountHUD(); if(collected>=TARGET_TREASURES) onWin(); }
  }
}

/* Win handling */
const winEl = document.getElementById('win');
function onWin(){
  winEl.style.display = 'block';
  // release pointer lock to let user click buttons if wanted
  if(document.pointerLockElement) document.exitPointerLock?.();
}

/* Drawing */
function clearBackground(){
  // Draw sky and floor: sky above horizon (simple gradient), floor as gradient below horizon
  // Horizon line at center - small offset based on pitch
  const horizon = H/2 + (player.pitch * FOCAL);
  // Sky
  const skyGrad = ctx.createLinearGradient(0,0,0,horizon);
  skyGrad.addColorStop(0, '#70a7ff');
  skyGrad.addColorStop(1, '#3b6fc0');
  ctx.fillStyle = skyGrad;
  ctx.fillRect(0,0,W, Math.max(0,horizon));

  // Floor / ground gradient for the rest
  const floorGrad = ctx.createLinearGradient(0,horizon,0,H);
  floorGrad.addColorStop(0, '#606060');
  floorGrad.addColorStop(1, '#2b2b2b');
  ctx.fillStyle = floorGrad;
  ctx.fillRect(0, Math.max(0,horizon), W, Math.max(0,H-horizon));
}

function draw(){
  // Clear background
  clearBackground();

  // Build polygon list (walls -> vertical quads), and billboards for treasures
  const polys = [];
  const camX = player.x, camZ = player.z, yaw = player.yaw;
  for(const w of walls){
    // Compute endpoints in camera space
    const p1 = worldToCamera(w.x1, w.z1, camX, camZ, yaw);
    const p2 = worldToCamera(w.x2, w.z2, camX, camZ, yaw);
    // If both behind near plane, skip; else clip roughly by ensuring at least one z>NEAR
    if(p1.z <= NEAR && p2.z <= NEAR) continue;

    // Build 3D vertices for top and bottom (y = 0..WALL_H)
    // We'll handle simple near-plane clipping by skipping polygons that cross very near, which is acceptable here
    const v1top = { x:p1.x, y:WALL_H - player.eye, z:p1.z };
    const v1bot = { x:p1.x, y: -player.eye, z:p1.z }; // bottom relative to camera (y down)
    const v2top = { x:p2.x, y:WALL_H - player.eye, z:p2.z };
    const v2bot = { x:p2.x, y: -player.eye, z:p2.z };

    // Filter vertices with z<=NEAR by small epsilon; if one vertex behind, we still project others — OK for small maze
    // Compute average depth for sorting
    const avgZ = (v1top.z + v1bot.z + v2top.z + v2bot.z) / 4;
    // Project valid vertices (must have z>NEAR)
    const pts = [];
    for(const v of [v1top,v1bot,v2bot,v2top]){ // order CCW for fill
      if(v.z <= NEAR){
        // push a far off-screen projection to keep polygon handled; but better to skip precise clipping for brevity
        pts.push({ x: v.x * 1000, y: v.y * 1000, z: v.z });
      } else {
        const pr = project(v.x, v.y, v.z);
        pts.push({ sx: pr.sx, sy: pr.sy, z: v.z });
      }
    }

    polys.push({ pts, depth: avgZ, worldZ: Math.min(p1.z,p2.z) });
  }

  // Treasure billboards: simple projected squares always facing camera; depth sorts by their z in camera space
  for(const t of treasures){
    if(t.collected) continue;
    const p = worldToCamera(t.x, t.z, camX, camZ, yaw);
    if(p.z <= NEAR) continue;
    const scale = (FOCAL / p.z) * 0.35; // size
    const sx = p.x * FOCAL / p.z + W/2;
    const sy = (-0.1 * FOCAL / p.z) + H/2; // slightly above floor
    const size = 22 * scale;
    const depth = p.z;
    polys.push({
      pts: [
        { sx: sx - size, sy: sy - size, z: depth },
        { sx: sx + size, sy: sy - size, z: depth },
        { sx: sx + size, sy: sy + size, z: depth },
        { sx: sx - size, sy: sy + size, z: depth }
      ],
      depth,
      treasure:true
    });
  }

  // Sort back-to-front (large z is farther because camera looks toward +z)
  polys.sort((a,b)=> b.depth - a.depth );

  // Draw polygons
  for(const poly of polys){
    // Compute brightness based on depth: closer => brighter
    const depth = poly.depth;
    // Clamp depth for shading
    const d = Math.max(0.1, Math.min(8, depth));
    const t = 1 - Math.min(1, d/6); // 1 near, 0 far
    if(poly.treasure){
      // gold color with brightness
      const r = Math.floor(180 + 75*t);
      const g = Math.floor(140 + 90*t);
      const b = Math.floor(30 + 30*t);
      ctx.fillStyle = `rgb(${r},${g},${b})`;
      ctx.beginPath();
      ctx.moveTo(poly.pts[0].sx, poly.pts[0].sy);
      for(let i=1;i<poly.pts.length;i++) ctx.lineTo(poly.pts[i].sx, poly.pts[i].sy);
      ctx.closePath();
      ctx.fill();
      // simple sparkle stroke
      ctx.strokeStyle = 'rgba(255,255,255,0.05)';
      ctx.stroke();
    } else {
      // Wall shading: base color dark stone
      const base = Math.floor(40 + 140*t);
      ctx.fillStyle = `rgb(${base},${base},${base+10})`;
      ctx.beginPath();
      // skip polys with invalid projected points
      if(poly.pts.some(p=>Number.isFinite(p.sx) && Number.isFinite(p.sy))){
        ctx.moveTo(poly.pts[0].sx, poly.pts[0].sy);
        for(let i=1;i<poly.pts.length;i++) ctx.lineTo(poly.pts[i].sx, poly.pts[i].sy);
        ctx.closePath();
        ctx.fill();
        // subtle edge
        ctx.strokeStyle = 'rgba(0,0,0,0.25)';
        ctx.stroke();
      }
    }
  }

  // HUD is DOM; nothing else to draw
}

/* Controls for UI buttons */
const centerPrompt = document.getElementById('centerPrompt');
document.getElementById('startBtn').addEventListener('click', ()=>{ canvas.requestPointerLock?.(); centerPrompt.style.display='none'; });
document.getElementById('regenBtn').addEventListener('click', ()=>{ generateMaze(); centerPrompt.style.display='none'; if(document.pointerLockElement) document.exitPointerLock?.(); });
document.getElementById('againBtn').addEventListener('click', ()=>{ winEl.style.display='none'; generateMaze(); centerPrompt.style.display=''; });

/* Init */
generateMaze();
requestAnimationFrame(render);
})();
</script>
</body>
</html>

--------------------

User prompt 4 of 6 - 3/19/2026, 11:24:00 PM:
Okay, very nice! That almost works, but it has a few problems. The mouselook and fullscreen work pretty okay. And I can see perspective-projected walls. But pressing D moves me "forward" and A moves "back", while S moves "left" and W moves "right", which suggests that either movement or projection is rotated 90° from what it should be. Also there's some kind of glitchy thing where sometimes the whole screen is yellow, and sometimes walls appear and disappear, which I think is maybe a treasure being drawn too big or something?  And pitching with the mouse doesn't really work, so maybe the camera space transformation isn't using the pitch angle or it isn't getting updated from the mouse movements. Possibly the painter's-algorithm ordering also isn't using an ordering consistent with the camera direction, because sometimes smaller walls get painted on top of bigger walls.

GPT-5 mini:
Thanks — that diagnosis is accurate. I'll fix these issues:

Planned fixes
- Movement directions: compute movement vector relative to yaw correctly (use forward along -z in camera space or adjust trig so W moves forward, S back, A left, D right).
- Pitch: include pitch in the camera-space transform for y-axis rotation so looking up/down affects projection.
- Treasure billboard sizing/clipping: limit billboard scale and skip rendering if projected size or depth is out-of-range to avoid giant yellow fills.
- Painter sorting: sort polygons by their minimal depth (or farthest vertex depth) and, for walls, use the average camera-space z of polygon vertices; use consistent camera-space z where positive is forward — ensure projection uses camera coordinates where z>NEAR are in front.
- Near-plane clipping: discard or clip polygons that cross the near plane to avoid huge projections.
- Robustness: guard against NaN/Infinity before drawing.

If that plan looks good I'll produce a corrected single-file HTML/JS with those fixes applied. Any other behavior or polish you want changed (movement speed, mouse sensitivity, treasure appearance, shading)?

--------------------

User prompt 5 of 6 - 3/19/2026, 11:24:35 PM:
Yeah, let's give that a try.

GPT-5 mini:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>First-Person Maze - Fixed</title>
<style>
  html,body { height:100%; margin:0; background:#000; font-family:system-ui,Segoe UI,Roboto,Arial; color:#fff; }
  canvas { display:block; width:100vw; height:100vh; background:#000; }
  #overlay {
    position: absolute; left:0; top:0; right:0; padding:12px;
    display:flex; justify-content:space-between; align-items:center; pointer-events:none;
  }
  .panel { pointer-events:auto; background:rgba(0,0,0,0.35); padding:8px 12px; border-radius:6px; }
  #centerPrompt {
    position: absolute; left:50%; top:50%; transform:translate(-50%,-50%);
    background:rgba(0,0,0,0.6); padding:14px 18px; border-radius:8px; text-align:center;
  }
  button { margin-left:8px; padding:6px 10px; border-radius:5px; border:1px solid rgba(255,255,255,0.15); background:#222; color:#fff; cursor:pointer; }
  button:active { transform:translateY(1px); }
  #win {
    position:absolute; left:50%; top:40%; transform:translateX(-50%); background:rgba(0,0,0,0.75); padding:18px 22px; border-radius:8px; display:none;
  }
</style>
</head>
<body>
<canvas id="c"></canvas>

<div id="overlay">
  <div class="panel" id="leftPanel">
    <strong>Treasures:</strong> <span id="count">0</span>/10
  </div>
  <div class="panel" id="rightPanel">
    <small>WASD: move</small> • <small>Click: enable mouse look</small> • <small>F: fullscreen</small>
  </div>
</div>

<div id="centerPrompt">
  <div style="font-weight:600; margin-bottom:8px;">Click to play — enable mouse look</div>
  <div style="font-size:13px; color:#ddd;">Use WASD to move. Collect all 10 treasures.</div>
  <div style="margin-top:10px;">
    <button id="startBtn">Start</button>
    <button id="regenBtn">New Maze</button>
  </div>
</div>

<div id="win">
  <div style="font-size:20px; font-weight:700; margin-bottom:8px;">You collected all treasures!</div>
  <div style="margin-bottom:12px;"><button id="againBtn">Play Again</button></div>
</div>

<script>
(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

let W=innerWidth, H=innerHeight;
function resize(){ W=canvas.width = innerWidth; H=canvas.height = innerHeight; updateFocal(); }
addEventListener('resize', resize);
resize();

/* Config */
const GRID = 10;
const CELL = 1.0;           // world units per cell
const WALL_H = 1.8;
const EYE_H = 1.6;
const NEAR = 0.08;
const FOV = 75 * Math.PI/180;
let FOCAL = 1;
function updateFocal(){ FOCAL = 0.5 * H / Math.tan(FOV/2); }
updateFocal();
let PLAYER_RADIUS = 0.3;    // must be < 0.5 cell and > NEAR
if (PLAYER_RADIUS >= 0.5) PLAYER_RADIUS = 0.49;
if (PLAYER_RADIUS <= NEAR) PLAYER_RADIUS = NEAR + 0.01;

/* State */
let walls = []; // wall segments: {x1,z1,x2,z2}
let treasures = []; // {x,z,collected}
let player = { x:0.5, z:0.5, yaw:0, pitch:0, speed:2.2, radius:PLAYER_RADIUS, eye:EYE_H };
let keys = {};
let pointerLocked = false;
let prevTime = performance.now();
let collected = 0;
const TARGET_TREASURES = 10;

/* Utility */
function randInt(n){ return Math.floor(Math.random()*n); }
function shuffle(arr){ for(let i=arr.length-1;i>0;i--){ const j=randInt(i+1); [arr[i],arr[j]]=[arr[j],arr[i]]; } }

/* Maze generation with union-find */
function generateMaze() {
  const parent = new Array(GRID*GRID).fill(-1);
  function find(a){ return parent[a]<0 ? a : parent[a]=find(parent[a]); }
  function union(a,b){
    a=find(a); b=find(b);
    if(a===b) return false;
    if(parent[a]>parent[b]) [a,b]=[b,a];
    parent[a]+=parent[b]; parent[b]=a;
    return true;
  }

  const wallList = [];
  for(let y=0;y<GRID;y++){
    for(let x=0;x<GRID;x++){
      const i = y*GRID + x;
      if(x<GRID-1){
        wallList.push({
          a: i,
          b: i+1,
          x1: (x+1)*CELL, z1: y*CELL,
          x2: (x+1)*CELL, z2: (y+1)*CELL,
          exists: true
        });
      }
      if(y<GRID-1){
        wallList.push({
          a: i,
          b: i+GRID,
          x1: x*CELL, z1: (y+1)*CELL,
          x2: (x+1)*CELL, z2: (y+1)*CELL,
          exists: true
        });
      }
    }
  }

  const order = Array.from({length:wallList.length}, (_,i)=>i);
  shuffle(order);
  for(const idx of order){
    const w = wallList[idx];
    if(union(w.a,w.b)){
      w.exists = false;
    } else {
      w.exists = true;
    }
  }

  const existingSeparators = wallList.filter(w=>w.exists);
  shuffle(existingSeparators);
  for(let i=0;i<3 && i<existingSeparators.length;i++){
    existingSeparators[i].exists = false;
  }

  walls = [];
  for(const w of wallList){
    if(w.exists) walls.push({ x1:w.x1, z1:w.z1, x2:w.x2, z2:w.z2 });
  }
  // Outer borders
  walls.push({ x1:0, z1:0, x2:GRID*CELL, z2:0 });
  walls.push({ x1:0, z1:GRID*CELL, x2:GRID*CELL, z2:GRID*CELL });
  walls.push({ x1:0, z1:0, x2:0, z2:GRID*CELL });
  walls.push({ x1:GRID*CELL, z1:0, x2:GRID*CELL, z2:GRID*CELL });

  // Place treasures and player
  treasures = [];
  const cellIndices = Array.from({length:GRID*GRID}, (_,i)=>i);
  shuffle(cellIndices);
  const startIdx = cellIndices[0];
  const sx = (startIdx % GRID) + 0.5, sy = Math.floor(startIdx / GRID) + 0.5;
  player.x = sx * CELL; player.z = sy * CELL; player.yaw = 0; player.pitch = 0;

  let placed = 0, j=1;
  while(placed<TARGET_TREASURES && j<cellIndices.length){
    const idx = cellIndices[j++];
    const cx = (idx % GRID) + 0.5;
    const cz = Math.floor(idx/GRID) + 0.5;
    // avoid start cell
    if(Math.hypot(cx - sx, cz - sy) < 0.5) continue;
    treasures.push({ x: cx*CELL, z: cz*CELL, collected:false });
    placed++;
  }
  collected = 0;
  updateCountHUD();
}

/* Input handling */
document.addEventListener('pointerlockchange', ()=> {
  pointerLocked = (document.pointerLockElement === canvas);
  centerPrompt.style.display = pointerLocked ? 'none' : '';
});
canvas.addEventListener('click', () => {
  canvas.requestPointerLock?.();
});
document.addEventListener('mousemove', (e) => {
  if(!pointerLocked) return;
  const sensitivity = 0.0026;
  player.yaw -= e.movementX * sensitivity;
  player.pitch -= e.movementY * sensitivity;
  const maxPitch = Math.PI/2 - 0.05;
  if(player.pitch > maxPitch) player.pitch = maxPitch;
  if(player.pitch < -maxPitch) player.pitch = -maxPitch;
});
document.addEventListener('keydown', e => {
  if(e.key === 'f' || e.key === 'F'){
    if(!document.fullscreenElement) document.documentElement.requestFullscreen?.();
    else document.exitFullscreen?.();
  }
  keys[e.key.toLowerCase()] = true;
});
document.addEventListener('keyup', e => {
  keys[e.key.toLowerCase()] = false;
});

/* Collision: distance from point to segment */
function pointSegDist(px,pz, x1,z1,x2,z2){
  const vx = x2-x1, vz = z2-z1;
  const wx = px-x1, wz = pz-z1;
  const denom = (vx*vx + vz*vz);
  if(denom === 0){
    return Math.hypot(px-x1, pz-z1);
  }
  const c = Math.max(0, Math.min(1, (wx*vx + wz*vz) / denom));
  const projx = x1 + c*vx, projz = z1 + c*vz;
  return Math.hypot(px-projx, pz-projz);
}

/* Update HUD */
const countEl = document.getElementById('count');
function updateCountHUD(){ countEl.textContent = collected; }

/* Camera math (include pitch) */
/* We'll convert world (X,Z) to camera space where:
   - camera looks along +Z_cam
   - X_cam to the right
   - Y_cam up
   We apply yaw (rotation around Y) then pitch (rotation around X). */
function worldToCameraVec(wx,wz, camX, camZ, yaw, pitch){
  // translate
  let rx = wx - camX;
  let rz = wz - camZ;
  // rotate by -yaw (world -> camera yaw)
  const sy = Math.sin(-yaw), cy = Math.cos(-yaw);
  let x = rx*cy - rz*sy;
  let z = rx*sy + rz*cy;
  // apply pitch rotation around camera's X axis to simulate looking up/down:
  // vertical positions are handled separately when projecting vertical vertices (we'll subtract eye height)
  return { x, z, yaw, pitch };
}
function projectVertex(camX, camY, camZ){
  // camZ is forward; camY is up; we want z>NEAR to project
  if(camZ <= NEAR) return null;
  const sx = (camX * FOCAL) / camZ + W/2;
  const sy = ( -camY * FOCAL) / camZ + H/2;
  return { sx, sy, z: camZ };
}

/* Render loop */
function render(now){
  const dt = Math.min(0.06, (now - prevTime)/1000);
  prevTime = now;
  update(dt);
  draw();
  requestAnimationFrame(render);
}

/* Update player movement and treasures */
function update(dt){
  // movement input: forward should move in direction of yaw (W forward), strafe left/right
  let fw = 0, str = 0;
  if(keys['w']) fw += 1;
  if(keys['s']) fw -= 1;
  if(keys['a']) str -= 1;
  if(keys['d']) str += 1;
  const ilen = Math.hypot(fw, str) || 1;
  fw /= ilen; str /= ilen;
  // Compute movement in world space: forward vector is (cos(yaw), sin(yaw)) in XZ
  const fx = Math.cos(player.yaw) * fw;
  const fz = Math.sin(player.yaw) * fw;
  const sx = Math.cos(player.yaw + Math.PI/2) * str;
  const sz = Math.sin(player.yaw + Math.PI/2) * str;
  const dx = (fx + sx) * player.speed * dt;
  const dz = (fz + sz) * player.speed * dt;

  // Attempt move with per-axis collision
  let nx = player.x + dx, nz = player.z + dz;
  // X axis check
  let collideX = false;
  for(const w of walls){
    const d = pointSegDist(nx, player.z, w.x1, w.z1, w.x2, w.z2);
    if(d < player.radius - 1e-6){ collideX = true; break; }
  }
  if(collideX) nx = player.x;
  // Z axis check
  let collideZ = false;
  for(const w of walls){
    const d = pointSegDist(player.x, nz, w.x1, w.z1, w.x2, w.z2);
    if(d < player.radius - 1e-6){ collideZ = true; break; }
  }
  if(collideZ) nz = player.z;

  player.x = nx; player.z = nz;

  // Check treasure pickup
  for(const t of treasures){
    if(t.collected) continue;
    const d = Math.hypot(player.x - t.x, player.z - t.z);
    if(d < 0.45){ t.collected = true; collected++; updateCountHUD(); if(collected>=TARGET_TREASURES) onWin(); }
  }
}

/* Win handling */
const winEl = document.getElementById('win');
function onWin(){
  winEl.style.display = 'block';
  if(document.pointerLockElement) document.exitPointerLock?.();
}

/* Drawing */
function clearBackground(){
  const horizon = H/2 + (player.pitch * FOCAL);
  // Sky
  const skyGrad = ctx.createLinearGradient(0,0,0,Math.max(0,horizon));
  skyGrad.addColorStop(0, '#70a7ff');
  skyGrad.addColorStop(1, '#3b6fc0');
  ctx.fillStyle = skyGrad;
  ctx.fillRect(0,0,W, Math.max(0,horizon));

  // Floor
  const floorGrad = ctx.createLinearGradient(0,Math.max(0,horizon),0,H);
  floorGrad.addColorStop(0, '#606060');
  floorGrad.addColorStop(1, '#2b2b2b');
  ctx.fillStyle = floorGrad;
  ctx.fillRect(0, Math.max(0,horizon), W, Math.max(0,H-horizon));
}

function draw(){
  clearBackground();

  const camX = player.x, camZ = player.z, yaw = player.yaw, pitch = player.pitch;
  // We'll build polygons with camera-space vertices properly transformed using pitch for Y coordinates
  const polyList = [];

  // For each wall, generate a vertical quad. We'll compute each quad's vertices in camera space:
  for(const w of walls){
    // world endpoints
    const wx1 = w.x1, wz1 = w.z1, wx2 = w.x2, wz2 = w.z2;
    // Convert endpoints to camera XY (X right, Z forward). Use worldToCameraVec for X,Z; handle Y via vertical extents.
    const c1 = worldToCameraVec(wx1, wz1, camX, camZ, yaw, pitch);
    const c2 = worldToCameraVec(wx2, wz2, camX, camZ, yaw, pitch);
    // Now build four vertices in camera space with Y positions relative to eye and applying pitch rotation.
    // For each point, camera-space Y = (worldY - eye) rotated by pitch.
    // worldY for top = WALL_H, bottom = 0. So camY_top = (WALL_H - eye), camY_bot = (0 - eye)
    const yTop = WALL_H - player.eye;
    const yBot = 0 - player.eye;

    // Apply pitch rotation: rotate (y, z) by -pitch around X axis to align camera looking up/down.
    function applyPitch(y, camZ){
      const cp = Math.cos(-pitch), sp = Math.sin(-pitch);
      // after rotation: y' = y*cp - camZ*sp ; z' = y*sp + camZ*cp
      return { y: y*cp - camZ*sp, z: y*sp + camZ*cp };
    }

    const v1t = applyPitch(yTop, c1.z);
    const v1b = applyPitch(yBot, c1.z);
    const v2t = applyPitch(yTop, c2.z);
    const v2b = applyPitch(yBot, c2.z);

    // Now check if all four z' are <= NEAR -> skip
    const zvals = [v1t.z, v1b.z, v2t.z, v2b.z];
    if(zvals.every(z=> z <= NEAR)) continue;

    // Project points, skipping any with z<=NEAR (we will still attempt to project others; this is not full clipping but is robust enough)
    const pv = [];
    for(const v of [
      {x:c1.x, y:v1t.y, z:v1t.z},
      {x:c1.x, y:v1b.y, z:v1b.z},
      {x:c2.x, y:v2b.y, z:v2b.z},
      {x:c2.x, y:v2t.y, z:v2t.z}
    ]){
      if(!(isFinite(v.x) && isFinite(v.y) && isFinite(v.z))){
        pv.push(null);
      } else {
        const p = projectVertex(v.x, v.y, v.z);
        pv.push(p);
      }
    }
    // If fewer than 3 valid projected points, skip
    const validCount = pv.filter(p=>p && isFinite(p.sx) && isFinite(p.sy)).length;
    if(validCount < 3) continue;

    // compute depth metric: use minimum z (closer) for sorting purpose such that farther polygons draw first
    const avgZ = zvals.reduce((a,b)=>a+b,0)/zvals.length;
    const minZ = Math.min(...zvals);
    polyList.push({ pts: pv, depth: avgZ, sortZ:minZ, type:'wall' });
  }

  // Treasures: billboard quads facing camera; we will cap size and skip extremely close or behind
  for(const t of treasures){
    if(t.collected) continue;
    // Convert treasure position into camera-space x,z
    const c = worldToCameraVec(t.x, t.z, camX, camZ, yaw, pitch);
    // Place slightly above floor: worldY ~ 0.25 so height shows; compute top/bot after pitch
    const tworldY = 0.25 - player.eye;
    const pitchRot = (vY, camZ) => {
      const cp = Math.cos(-pitch), sp = Math.sin(-pitch);
      return { y: vY*cp - camZ*sp, z: vY*sp + camZ*cp };
    };
    const pr = pitchRot(tworldY, c.z);
    const camZforDepth = pr.z;
    if(camZforDepth <= NEAR) continue;
    // compute screen center
    const center = projectVertex(c.x, pr.y, pr.z);
    if(!center) continue;
    // size scale based on depth but clamped
    const rawScale = (FOCAL / camZforDepth) * 0.35;
    const sizePx = Math.max(6, Math.min(40, 22 * rawScale));
    // Build quad around center
    const sx = center.sx, sy = center.sy;
    const pts = [
      { sx: sx - sizePx, sy: sy - sizePx, z: camZforDepth },
      { sx: sx + sizePx, sy: sy - sizePx, z: camZforDepth },
      { sx: sx + sizePx, sy: sy + sizePx, z: camZforDepth },
      { sx: sx - sizePx, sy: sy + sizePx, z: camZforDepth }
    ];
    polyList.push({ pts, depth: camZforDepth, sortZ:camZforDepth, type:'treasure' });
  }

  // Sort polygons: farthest first. Use sortZ (min z) or depth average. We want larger sortZ -> farther.
  polyList.sort((a,b)=> b.sortZ - a.sortZ);

  // Draw polygons
  for(const poly of polyList){
    // Validate points
    const pts = poly.pts.filter(p=>p && isFinite(p.sx) && isFinite(p.sy));
    if(pts.length < 3) continue;
    // Depth shading factor (closer => brighter)
    const d = Math.max(0.1, Math.min(12, poly.depth));
    const t = 1 - Math.min(1, d/8); // 1 near, 0 far

    if(poly.type === 'treasure'){
      const r = Math.floor(180 + 75*t);
      const g = Math.floor(140 + 90*t);
      const b = Math.floor(30 + 30*t);
      ctx.fillStyle = `rgb(${r},${g},${b})`;
      ctx.beginPath();
      ctx.moveTo(pts[0].sx, pts[0].sy);
      for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].sx, pts[i].sy);
      ctx.closePath();
      ctx.fill();
      ctx.strokeStyle = 'rgba(255,255,255,0.06)';
      ctx.stroke();
    } else {
      const base = Math.floor(40 + 150*t);
      ctx.fillStyle = `rgb(${base},${base},${base+12})`;
      ctx.beginPath();
      ctx.moveTo(pts[0].sx, pts[0].sy);
      for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].sx, pts[i].sy);
      ctx.closePath();
      ctx.fill();
      ctx.strokeStyle = 'rgba(0,0,0,0.25)';
      ctx.stroke();
    }
  }
}

/* Controls for UI buttons */
const centerPrompt = document.getElementById('centerPrompt');
document.getElementById('startBtn').addEventListener('click', ()=>{ canvas.requestPointerLock?.(); centerPrompt.style.display='none'; });
document.getElementById('regenBtn').addEventListener('click', ()=>{ generateMaze(); centerPrompt.style.display='none'; if(document.pointerLockElement) document.exitPointerLock?.(); });
document.getElementById('againBtn').addEventListener('click', ()=>{ winEl.style.display = 'none'; generateMaze(); centerPrompt.style.display=''; });

/* Init */
generateMaze();
requestAnimationFrame(render);
})();
</script>
</body>
</html>

--------------------

User prompt 6 of 6 - 3/19/2026, 11:45:58 PM:
That seems a bit closer, though I had to remove the initial top-level resize() call before the initialization off FOV, changing the following updateFocal() to a resize(). I was able to play through the game and collect 10 treasures and get the win screen, but there are a lot of display glitches still.  Walls disappear when I get close to them, allowing me to see treasures I shouldn't, and sometimes huge stretchy quads appear. And the mouse pitch movement moves the artificial horizon and the walls in opposite directions on the display.  Also D still moves forward (relative to the view direction) rather than sideways.  I think I can probably fix it from here, though.

GPT-5 mini:
Thanks — glad you could make progress. A few quick notes that should help you finish-cleanly if you want to keep iterating:

1) Movement direction (D strafing)
- In the latest code the strafe vector was computed as cos(yaw+90°), sin(yaw+90°), which is correct for right; if D still moves forward it means fw/str are being mixed or normalized incorrectly. Ensure you normalize the pair (fw, str) only for magnitude and then multiply the world vectors separately:
  - fx = cos(yaw) * fw
  - fz = sin(yaw) * fw
  - sx = cos(yaw + PI/2) * str
  - sz = sin(yaw + PI/2) * str
  - dx = (fx + sx) * speed * dt
  - dz = (fz + sz) * speed * dt

2) Pitch / horizon inversion and visible-polygon vertical transform
- The visual mismatch you described (horizon and walls moving opposite on pitch) comes from inconsistent sign conventions when applying pitch:
  - Decide on one convention: camera looks along +Z_cam; positive pitch = look up (rotate camera so scene moves down).
  - When rotating vertices by pitch around camera X axis, rotate world (Y,Z) by angle = +pitch for world→camera (or -pitch for camera→world) consistently.
  - If horizon moves opposite, flip the sign used in the pitch rotation (try using +pitch instead of -pitch or vice versa) for both top/bottom Y and treasure Y when converting to camera-space before projection.

3) Near-plane / huge stretched quads and z-clipping
- Stretched quads and disappearing walls almost always happen when polygons cross the near plane. Two approaches:
  - Robust approach: clip polygons against z = NEAR in camera space producing new vertices on the near-plane and then project only the clipped polygon. This is the correct, but more code-heavy fix.
  - Simple practical fix: discard any polygon where any projected vertex has camZ <= NEAR, or discard polygon if min(camZ) < NEAR and max(camZ) / min(camZ) > some large factor (e.g., 50). That prevents giant projections but may cause popping near walls.
  - For walls that disappear when you get close, ensure you compute the per-vertex camZ after pitch rotation (not before), and use min(camZ) or average camZ for sorting and visibility checks.

4) Sorting / painter's algorithm
- Sort using the maximum of the polygon's camZ values (farthest point) or the average camZ after the pitch transform. If polygons intersect in depth, painter's algorithm can still fail — for a small maze it's usually fine when you:
  - Compute cam-space z for each polygon vertex after all rotations.
  - Set sortKey = average(camZ) or sortKey = max(camZ) (both work; use average for more stable results).
  - Sort descending (draw farthest first).

5) Treasure billboards
- Cap billboard size and ignore billboards where camZ is too small; compute their camZ after applying pitch rotation to match walls.
- Also avoid drawing billboards whose projected bounding box covers a huge portion of the screen (skip if sizePx > some fraction of min(W,H)).

6) Debugging tips
- Visualize cam-space z as an overlay color or draw wireframes with stroke to see which polygons are being included and their projected z.
- Log or draw a small cross at the projected endpoints and the computed camZ to confirm transformations.
- Temporarily render near-plane in a distinct color so you can see where it is relative to the player.

If you want, I can:
- Produce a corrected version that implements proper near-plane clipping for quads and fixes the pitch sign (and verifies strafe). 
- Or provide a compact patch (diff) with the exact line fixes to the last file you ran.

Which would you prefer?