Showcase

One Portal test chamber, built by Claude Haiku 5.5

Hostedvia Claude CodeOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML25,953 characters
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portal Chamber</title>
<style>
  :root { --ink: #e9eef4; --dim: #9aa6b2; color-scheme: dark; }
  html, body { margin: 0; height: 100%; background: #1b1f24; overflow: hidden; }
  body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); }
  canvas { display: block; width: 100vw; height: 100vh; }
  #dot { position: fixed; left: 50%; top: 50%; width: 6px; height: 6px; margin: -3px 0 0 -3px;
         border-radius: 50%; background: #fff; box-shadow: 0 0 0 1px rgba(0, 0, 0, .45); pointer-events: none; }
  .screen { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center;
            justify-content: center; gap: 14px; padding: 16px; box-sizing: border-box; text-align: center; }
  .hidden { display: none !important; }
  #overlay { background: rgba(8, 10, 13, .42); cursor: pointer; }
  #overlay h1 { margin: 0; font-size: clamp(28px, 6vw, 56px); font-weight: 300;
                letter-spacing: .18em; text-transform: uppercase; }
  #overlayMsg { font-size: 18px; }
  .keys { font-size: 14px; color: var(--dim); line-height: 1.7; max-width: 560px; }
  #won { background: rgba(8, 10, 13, .6); }
  #won .card { background: #242a31; border: 1px solid #3a424c; border-radius: 14px; padding: 28px 32px;
               min-width: 260px; box-shadow: 0 20px 60px rgba(0, 0, 0, .5); }
  #won h2 { margin: 0 0 6px; font-weight: 500; font-size: 24px; }
  #won p { margin: 0 0 18px; color: var(--dim); font-size: 14px; }
  #won button { font: inherit; font-size: 15px; padding: 10px 22px; border-radius: 8px; border: 0;
                background: var(--ink); color: #14181d; cursor: pointer; }
  #won button:hover { background: #fff; }
</style>
</head>
<body>
<div id="dot"></div>

<div id="overlay" class="screen">
  <h1>Portal Chamber</h1>
  <div id="overlayMsg">Click to start</div>
  <div class="keys">WASD move · Space jump · Mouse look<br>
    Left click: blue portal · Right click: orange portal<br>Esc to pause</div>
</div>

<div id="won" class="screen hidden">
  <div class="card">
    <h2>Chamber complete</h2>
    <p>Portals are a two-way street. Well done.</p>
    <button id="restart">Restart chamber</button>
  </div>
</div>

<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';

// ---------- layout (metres, y up) ----------
const ROOM = { hx: 7, hz: 7, h: 6 };
const LEDGE = { x0: -3, x1: 3, z0: -7, z1: -4.5, top: 3.2 };
const DOOR = { x: -1.9, y: 4.35, w: 1.4, h: 2.3 };
const START = new THREE.Vector3(0, 0, 3);
const DEMO_START = new THREE.Vector3(-2, 0, 0);
const DEMO_YAW = -Math.PI / 2;               // faces +x, toward the blue portal

// ---------- player / portal tuning ----------
const EYE = 1.6, CHEST = 0.8, BODY_R = 0.3, STEP = 0.6;
const WALK = 4.2, JUMP = 6.6, GRAVITY = 20;
const PORTAL_HW = 0.6, PORTAL_HH = 1.0;
const CHEST_V = new THREE.Vector3(0, CHEST, 0);
const UP = new THREE.Vector3(0, 1, 0);
const NO_INPUT = { f: 0, s: 0, l: 0, r: 0, jump: false };
const V = (x, y, z) => new THREE.Vector3(x, y, z);

// ---------- renderer / scene / camera ----------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.prepend(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1b1f24);
const camera = new THREE.PerspectiveCamera(74, innerWidth / innerHeight, 0.02, 80);
camera.rotation.order = 'YXZ';
scene.add(camera);

// ---------- lights ----------
scene.add(new THREE.HemisphereLight(0xe3ebf5, 0x3a3f46, 1.0));
scene.add(new THREE.AmbientLight(0xffffff, 0.6));   // fill so the white panels read as white
const sun = new THREE.DirectionalLight(0xffffff, 1.9);
sun.position.set(4, 10, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 1, far: 30 });
sun.shadow.camera.updateProjectionMatrix();
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.02;
scene.add(sun);
// The room is static, so the shadow map is baked once instead of per render pass.
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true;

// ---------- materials ----------
function seamTexture(bg, line, lw) {
  const c = document.createElement('canvas');
  c.width = c.height = 256;
  const g = c.getContext('2d');
  g.fillStyle = bg;
  g.fillRect(0, 0, 256, 256);
  g.strokeStyle = line;
  g.lineWidth = lw;
  g.strokeRect(lw / 2, lw / 2, 256 - lw, 256 - lw);
  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.colorSpace = THREE.SRGBColorSpace;
  t.anisotropy = 8;
  return t;
}
const whiteSeam = seamTexture('#eef2f5', '#aeb7c1', 7);
const floorSeam = seamTexture('#565e68', '#3a4048', 9);
floorSeam.repeat.set(ROOM.hx, ROOM.hz);     // 2 m tiles across the 14 m floor
const matDark = new THREE.MeshStandardMaterial({ color: 0x59616c, metalness: 0.3, roughness: 0.5 });
const matFloor = new THREE.MeshStandardMaterial({ map: floorSeam, metalness: 0.2, roughness: 0.6 });
function whiteMat(w, h) {
  const t = whiteSeam.clone();
  t.repeat.set(w / 2, h / 2);                // one seam every 2 m
  t.needsUpdate = true;
  return new THREE.MeshStandardMaterial({ map: t, roughness: 0.8, metalness: 0 });
}

// ---------- room ----------
const solids = [];   // everything a shot can hit
const panels = [];   // white, portal-able surfaces

function wall(c, n, w, h, white) {
  const r = new THREE.Vector3().crossVectors(UP, n).normalize();
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), white ? whiteMat(w, h) : matDark);
  mesh.position.copy(c).addScaledVector(n, white ? 0.01 : 0);
  mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(r, UP, n));
  mesh.receiveShadow = true;
  scene.add(mesh);
  solids.push(mesh);
  if (white) {
    const rec = { c0: c.clone(), n: n.clone(), r, u: UP.clone(), w, h };
    panels.push(rec);
    mesh.userData.panel = rec;
  }
}

const floor = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), matFloor);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
solids.push(floor);

const ceiling = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), matDark);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.y = ROOM.h;
scene.add(ceiling);
solids.push(ceiling);

// right wall (x = +7)
wall(V(7, 3, 0), V(-1, 0, 0), 14, 6, false);
wall(V(7, 3, 0), V(-1, 0, 0), 12, 6, true);
// left wall (x = -7)
wall(V(-7, 3, 0), V(1, 0, 0), 14, 6, false);
wall(V(-7, 3, 0), V(1, 0, 0), 12, 6, true);
// front wall (z = +7)
wall(V(0, 3, 7), V(0, 0, -1), 14, 6, false);
wall(V(0, 3, 7), V(0, 0, -1), 12, 6, true);
// back wall (z = -7): dark metal, white only above the ledge, with a gap for the door
wall(V(0, 3, -7), V(0, 0, 1), 14, 6, false);
wall(V(-4.8, 4.6, -7), V(0, 0, 1), 4.4, 2.8, true);
wall(V(2.9, 4.6, -7), V(0, 0, 1), 8.2, 2.8, true);

// ledge: too high to jump onto
const ledge = new THREE.Mesh(
  new THREE.BoxGeometry(LEDGE.x1 - LEDGE.x0, LEDGE.top, LEDGE.z1 - LEDGE.z0), matDark);
ledge.position.set((LEDGE.x0 + LEDGE.x1) / 2, LEDGE.top / 2, (LEDGE.z0 + LEDGE.z1) / 2);
ledge.castShadow = true;
ledge.receiveShadow = true;
scene.add(ledge);
solids.push(ledge);

// exit door on the ledge's wall
const doorFrame = new THREE.Mesh(new THREE.PlaneGeometry(DOOR.w, DOOR.h),
  new THREE.MeshStandardMaterial({ color: 0x20262d, metalness: 0.7, roughness: 0.35 }));
doorFrame.position.set(DOOR.x, DOOR.y, -6.985);
scene.add(doorFrame);
solids.push(doorFrame);
const doorGlow = new THREE.Mesh(new THREE.PlaneGeometry(DOOR.w - 0.3, DOOR.h - 0.3),
  new THREE.MeshStandardMaterial({ color: 0x0b2b24, emissive: 0x2a9d7a, emissiveIntensity: 0.35 }));
doorGlow.position.set(DOOR.x, DOOR.y, -6.98);
scene.add(doorGlow);
solids.push(doorGlow);

// ceiling light strips
const lightMat = new THREE.MeshBasicMaterial({ color: 0xf3f7fb });
for (const x of [-3.5, 3.5]) {
  const strip = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 8), lightMat);
  strip.rotation.x = Math.PI / 2;
  strip.position.set(x, ROOM.h - 0.03, 0);
  scene.add(strip);
}

// ---------- portal gun (viewmodel) ----------
const gun = new THREE.Group();
const gunBody = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.14, 0.46),
  new THREE.MeshStandardMaterial({ color: 0x9aa3ad, metalness: 0.8, roughness: 0.3 }));
gunBody.position.z = -0.12;
const gunNozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.14, 16),
  new THREE.MeshStandardMaterial({ color: 0x5b636d, metalness: 0.8, roughness: 0.35 }));
gunNozzle.rotation.x = Math.PI / 2;
gunNozzle.position.z = -0.4;
const gunBlue = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 8), new THREE.MeshBasicMaterial({ color: 0x3aa0ff }));
gunBlue.position.set(-0.07, 0.09, -0.3);
const gunOrange = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 8), new THREE.MeshBasicMaterial({ color: 0xff8c2a }));
gunOrange.position.set(0.07, 0.09, -0.3);
gun.add(gunBody, gunNozzle, gunBlue, gunOrange);
gun.position.set(0.3, -0.27, -0.5);
camera.add(gun);

// ---------- portals ----------
const VERT = `
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;
// Inside the rim we sample the partner's view by screen position: the virtual camera
// is built so its image lines up with this pixel. Outside the rim we draw a soft glow.
const FRAG = `
uniform sampler2D tex;
uniform vec2 res;
uniform vec3 glowColor;
uniform float time;
uniform vec2 halfSize;
uniform vec2 quadSize;
varying vec2 vUv;
void main() {
  vec2 q = (vUv - 0.5) * quadSize;
  float d = length(q / halfSize);
  float edge = abs(d - 1.0);
  float rim = exp(-edge * edge * 1800.0) + 0.35 * exp(-edge * 9.0);
  rim *= 0.9 + 0.1 * sin(time * 3.0);
  if (d < 1.0) {
    vec3 seen = texture2D(tex, gl_FragCoord.xy / res).rgb;
    gl_FragColor = vec4(seen + glowColor * rim, 1.0);
  } else {
    gl_FragColor = vec4(glowColor * rim, clamp(rim, 0.0, 1.0));
  }
  #include <colorspace_fragment>
}`;

function makePortal(hex) {
  const rt = new THREE.WebGLRenderTarget(4, 4);
  const material = new THREE.ShaderMaterial({
    uniforms: {
      tex: { value: rt.texture },
      res: { value: new THREE.Vector2(1, 1) },
      glowColor: { value: new THREE.Color(hex) },
      time: { value: 0 },
      halfSize: { value: new THREE.Vector2(PORTAL_HW, PORTAL_HH) },
      quadSize: { value: new THREE.Vector2(PORTAL_HW * 2.5, PORTAL_HH * 2.5) },
    },
    vertexShader: VERT,
    fragmentShader: FRAG,
    transparent: true,
  });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(PORTAL_HW * 2.5, PORTAL_HH * 2.5), material);
  mesh.visible = false;
  scene.add(mesh);
  return {
    placed: false, visible: false,
    c: new THREE.Vector3(), n: new THREE.Vector3(), r: new THREE.Vector3(), u: UP.clone(),
    rt, material, mesh, cam: new THREE.PerspectiveCamera(),
  };
}
const portals = { blue: makePortal(0x3aa0ff), orange: makePortal(0xff8c2a) };
const PORTAL_LIST = [portals.blue, portals.orange];
const other = P => (P === portals.blue ? portals.orange : portals.blue);

const drawSize = new THREE.Vector2();
function syncSize() {
  renderer.getDrawingBufferSize(drawSize);
  for (const P of PORTAL_LIST) {
    P.rt.setSize(drawSize.x, drawSize.y);
    P.material.uniforms.res.value.copy(drawSize);
  }
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
}
syncSize();

// Frame of a portal: the portal's room-side normal and its right/up axes.
// Frame A maps onto frame B with the normal reversed and a 180° turn about up,
// so walking in at A walks out at B facing away from the wall.
function portalRotation(P, Q) {
  const B = new THREE.Matrix4().makeBasis(Q.r.clone().negate(), Q.u, Q.n.clone().negate());
  const A = new THREE.Matrix4().makeBasis(P.r, P.u, P.n).transpose();
  return B.multiply(A);
}
function portalTransform(P, Q) {
  return new THREE.Matrix4()
    .makeTranslation(Q.c.x, Q.c.y, Q.c.z)
    .multiply(portalRotation(P, Q))
    .multiply(new THREE.Matrix4().makeTranslation(-P.c.x, -P.c.y, -P.c.z));
}

// Oblique near plane (Lengyel): clip everything behind the partner portal's wall.
function obliqueClip(vc, point, normal) {
  const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point).applyMatrix4(vc.matrixWorldInverse);
  const clip = new THREE.Vector4(plane.normal.x, plane.normal.y, plane.normal.z, plane.constant);
  const e = vc.projectionMatrix.elements;
  const q = new THREE.Vector4(
    (Math.sign(clip.x) + e[8]) / e[0],
    (Math.sign(clip.y) + e[9]) / e[5],
    -1.0,
    (1.0 + e[10]) / e[14]);
  clip.multiplyScalar(2.0 / clip.dot(q));
  e[2] = clip.x;
  e[6] = clip.y;
  e[10] = clip.z + 1.0;
  e[14] = clip.w;
}

// Render what is visible through P: the scene seen from behind Q, as if the camera stood at P.
function renderView(P, Q) {
  const vc = P.cam;
  const M = new THREE.Matrix4().multiplyMatrices(portalTransform(P, Q), camera.matrixWorld);
  M.decompose(vc.position, vc.quaternion, vc.scale);
  vc.updateMatrixWorld(true);
  vc.projectionMatrix.copy(camera.projectionMatrix);
  obliqueClip(vc, Q.c, Q.n);
  renderer.setRenderTarget(P.rt);
  renderer.clear();
  renderer.render(scene, vc);
  renderer.setRenderTarget(null);
}

function renderFrame() {
  gun.visible = false;
  for (const P of PORTAL_LIST) P.mesh.visible = false;
  for (const P of PORTAL_LIST) {
    P.visible = P.placed && camera.position.clone().sub(P.c).dot(P.n) > 0;
    if (!P.visible) continue;
    if (other(P).placed) {
      renderView(P, other(P));
    } else {
      renderer.setRenderTarget(P.rt);
      renderer.setClearColor(0x06080b, 1);
      renderer.clear();
      renderer.setRenderTarget(null);
    }
  }
  for (const P of PORTAL_LIST) P.mesh.visible = P.visible;
  gun.visible = true;
  renderer.render(scene, camera);
}

function placePortal(P, point, panel) {
  const mx = panel.w / 2 - PORTAL_HW, my = panel.h / 2 - PORTAL_HH;
  if (mx < 0 || my < 0) return false;          // panel too small for a portal
  const d = point.clone().sub(panel.c0);
  const lx = THREE.MathUtils.clamp(d.dot(panel.r), -mx, mx);
  let ly = THREE.MathUtils.clamp(d.dot(panel.u), -my, my);
  // Snap the bottom edge onto the floor, or onto the ledge top on the ledge wall, when close.
  // Walking through then lands at exactly the same height, with no step.
  const supports = [0];
  const wx = panel.c0.x + panel.r.x * lx;
  if (panel.n.z > 0.99 && wx > LEDGE.x0 && wx < LEDGE.x1) supports.push(LEDGE.top);
  for (const s of supports) {
    const snapped = s + PORTAL_HH - panel.c0.y;
    if (Math.abs(snapped) <= my && Math.abs(panel.c0.y + ly - PORTAL_HH - s) < 0.75) ly = snapped;
  }
  P.c.copy(panel.c0).addScaledVector(panel.r, lx).addScaledVector(panel.u, ly);
  P.n.copy(panel.n);
  P.r.copy(panel.r);
  P.u.copy(panel.u);
  P.placed = true;
  P.mesh.position.copy(P.c).addScaledVector(P.n, 0.01);
  P.mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(P.r, P.u, P.n));
  return true;
}

function findPanel(p) {
  return panels.find(rec => {
    const d = p.clone().sub(rec.c0);
    return Math.abs(d.dot(rec.n)) < 0.05 && Math.abs(d.dot(rec.r)) <= rec.w / 2 && Math.abs(d.dot(rec.u)) <= rec.h / 2;
  });
}

// ---------- fizzle effect ----------
const fx = [];
function fizzle(point, normal, color) {
  const ring = new THREE.Mesh(new THREE.RingGeometry(0.12, 0.18, 32),
    new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, side: THREE.DoubleSide }));
  ring.position.copy(point).addScaledVector(normal, 0.03);
  ring.lookAt(point.clone().add(normal));
  scene.add(ring);
  fx.push({ mesh: ring, t: 0 });
}
function updateFx(dt) {
  for (let i = fx.length - 1; i >= 0; i--) {
    const f = fx[i];
    f.t += dt;
    const k = f.t / 0.45;
    f.mesh.scale.setScalar(0.6 + k * 1.6);
    f.mesh.material.opacity = Math.max(0, 1 - k);
    if (k >= 1) {
      scene.remove(f.mesh);
      f.mesh.geometry.dispose();
      f.mesh.material.dispose();
      fx.splice(i, 1);
    }
  }
}

// ---------- shooting ----------
const raycaster = new THREE.Raycaster();
const CENTER = new THREE.Vector2(0, 0);
function shoot(name) {
  if (mode !== 'play') return;
  camera.updateMatrixWorld();
  raycaster.setFromCamera(CENTER, camera);
  const hit = raycaster.intersectObjects(solids, false)[0];
  const P = portals[name];
  if (hit && hit.object.userData.panel && placePortal(P, hit.point, hit.object.userData.panel)) return;
  const at = hit ? hit.point : raycaster.ray.at(6, new THREE.Vector3());
  const nrm = hit
    ? hit.face.normal.clone().transformDirection(hit.object.matrixWorld)
    : raycaster.ray.direction.clone().negate();
  fizzle(at, nrm, P.material.uniforms.glowColor.value);
}

// ---------- player ----------
const player = { pos: START.clone(), vel: new THREE.Vector3(), yaw: 0, pitch: 0, grounded: true, viewLift: 0 };

function teleport(P, Q, feet) {
  const R = portalRotation(P, Q);
  player.pos.copy(feet).applyMatrix4(portalTransform(P, Q));
  // Never come out below the exit's bottom edge, so the body is not left inside the ledge.
  // The eye keeps its mapped height and eases down to the real one, so the view does not jump.
  const mappedY = player.pos.y;
  player.pos.y = Math.max(mappedY, Q.c.y - PORTAL_HH);
  player.viewLift += mappedY - player.pos.y;
  player.vel.applyMatrix4(R);
  const f = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw)).applyMatrix4(R);
  player.yaw = Math.atan2(-f.x, -f.z);
  if (mode === 'demo') { demo.teleported = true; demo.t0 = demo.t; }
}

function inLedgeFootprint(x, z) {
  return x > LEDGE.x0 && x < LEDGE.x1 && z > LEDGE.z0 && z < LEDGE.z1;
}
function pushOutOfLedge(p) {
  const x0 = LEDGE.x0 - BODY_R, x1 = LEDGE.x1 + BODY_R, z1 = LEDGE.z1 + BODY_R;
  if (p.x <= x0 || p.x >= x1 || p.z <= LEDGE.z0 || p.z >= z1) return;
  const left = p.x - x0, right = x1 - p.x, front = z1 - p.z;
  if (left <= right && left <= front) p.x = x0;
  else if (right <= front) p.x = x1;
  else p.z = z1;
}
// The chest may pass a wall only through a placed portal's opening.
function inOpening(c, inward) {
  for (const P of PORTAL_LIST) {
    if (!P.placed || P.n.dot(inward) < 0.99) continue;
    const d = c.clone().sub(P.c);
    if (Math.abs(d.dot(P.n)) > 0.9) continue;
    if ((d.dot(P.r) / (PORTAL_HW + 0.15)) ** 2 + (d.dot(P.u) / (PORTAL_HH + 0.15)) ** 2 < 1) return true;
  }
  return false;
}
const WALLS = [
  { axis: 'x', sign: 1, inward: V(-1, 0, 0) },
  { axis: 'x', sign: -1, inward: V(1, 0, 0) },
  { axis: 'z', sign: 1, inward: V(0, 0, -1) },
  { axis: 'z', sign: -1, inward: V(0, 0, 1) },
];
const LIMIT = ROOM.hx - BODY_R;
function clampToRoom(p) {
  for (const w of WALLS) {
    if (p[w.axis] * w.sign <= LIMIT) continue;
    if (inOpening(p.clone().add(CHEST_V), w.inward)) continue;
    p[w.axis] = w.sign * LIMIT;
  }
}

function stepPlayer(sx, sy, sz) {
  const pos = player.pos;
  const prev = pos.clone();
  const next = prev.clone().add(V(sx, sy, sz));

  // 1) Crossing a placed portal's opening (judged by the chest) teleports the player.
  const cPrev = prev.clone().add(CHEST_V), cNext = next.clone().add(CHEST_V);
  for (const P of PORTAL_LIST) {
    const Q = other(P);
    if (!P.placed || !Q.placed) continue;
    const aPrev = cPrev.clone().sub(P.c).dot(P.n);
    const aNext = cNext.clone().sub(P.c).dot(P.n);
    if (aPrev < 0 || aNext >= 0) continue;
    const X = cPrev.clone().lerp(cNext, aPrev / (aPrev - aNext)).sub(P.c);
    if ((X.dot(P.r) / PORTAL_HW) ** 2 + (X.dot(P.u) / PORTAL_HH) ** 2 < 1) {
      teleport(P, Q, next);
      return;
    }
  }

  // 2) The ledge is solid while the player is below its top; they cannot step up onto it.
  if (prev.y < LEDGE.top - STEP) pushOutOfLedge(next);
  // 3) Room walls.
  clampToRoom(next);
  // 4) Ground: the floor, or the ledge top when the player is already at that height.
  const onLedge = prev.y >= LEDGE.top - STEP && inLedgeFootprint(next.x, next.z);
  const ground = onLedge ? LEDGE.top : 0;
  if (next.y <= ground) {
    next.y = ground;
    player.vel.y = 0;
    player.grounded = true;
  } else {
    player.grounded = false;
  }
  pos.copy(next);
}

function update(input, dt) {
  const fwd = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
  const right = new THREE.Vector3(-fwd.z, 0, fwd.x);
  const wish = fwd.multiplyScalar(input.f - input.s).addScaledVector(right, input.r - input.l);
  if (wish.lengthSq() > 0) wish.normalize().multiplyScalar(WALK);
  player.vel.x = wish.x;
  player.vel.z = wish.z;
  if (input.jump && player.grounded) {
    player.vel.y = JUMP;
    player.grounded = false;
  }
  player.vel.y -= GRAVITY * dt;
  const steps = Math.max(1, Math.ceil(player.vel.length() * dt / 0.04));
  for (let i = 0; i < steps; i++) {
    stepPlayer(player.vel.x * dt / steps, player.vel.y * dt / steps, player.vel.z * dt / steps);
  }
}

function syncCamera() {
  camera.position.set(player.pos.x, player.pos.y + EYE + player.viewLift, player.pos.z);
  camera.rotation.set(player.pitch, player.yaw, 0);
  camera.updateMatrixWorld();
}

// ---------- modes and UI ----------
const overlay = document.getElementById('overlay');
const overlayMsg = document.getElementById('overlayMsg');
const wonCard = document.getElementById('won');
let mode = 'demo';          // demo | play | paused | won

function showOverlay(on, msg) {
  overlay.classList.toggle('hidden', !on);
  if (msg) overlayMsg.textContent = msg;
}
function lock() {
  const p = renderer.domElement.requestPointerLock();
  // Chrome refuses a re-lock right after Esc; fall back to the paused overlay so the next click works.
  if (p && p.catch) p.catch(() => {
    if (mode === 'play') {
      mode = 'paused';
      showOverlay(true, 'Click to resume');
    }
  });
}
function resetChamber() {
  for (const P of PORTAL_LIST) { P.placed = false; P.mesh.visible = false; }
  player.pos.copy(START);
  player.vel.set(0, 0, 0);
  player.yaw = 0;
  player.pitch = 0;
  player.grounded = true;
  player.viewLift = 0;
}
function startGame() {
  resetChamber();
  mode = 'play';
  wonCard.classList.add('hidden');
  showOverlay(false);
  lock();
}
function resumeGame() {
  mode = 'play';
  showOverlay(false);
  lock();
}
function checkWin() {
  const p = player.pos;
  if (mode === 'play' && Math.abs(p.x - DOOR.x) < 0.8 && p.z < -6.0 && Math.abs(p.y - LEDGE.top) < 0.3) {
    mode = 'won';
    showOverlay(false);
    wonCard.classList.remove('hidden');
    if (document.pointerLockElement) document.exitPointerLock();
  }
}

// ---------- attract demo: walk into blue, come out of orange ----------
const demo = { t: 0, teleported: false, t0: 0 };
function resetDemo() {
  player.pos.copy(DEMO_START);
  player.vel.set(0, 0, 0);
  player.yaw = DEMO_YAW;
  player.pitch = 0;
  player.grounded = true;
  player.viewLift = 0;
  demo.t = 0;
  demo.teleported = false;
}
function demoInput(dt) {
  demo.t += dt;
  if (!demo.teleported) return { f: 1, s: 0, l: 0, r: 0, jump: false };
  player.yaw += (Math.PI / 2 - player.yaw) * Math.min(1, dt * 2);   // turn toward the door
  if (demo.t - demo.t0 > 3.5) resetDemo();
  return NO_INPUT;
}

// ---------- input ----------
const keys = new Set();
const held = c => (keys.has(c) ? 1 : 0);
function liveInput() {
  return { f: held('KeyW'), s: held('KeyS'), l: held('KeyA'), r: held('KeyD'), jump: keys.has('Space') };
}
// Some synthetic key events arrive with an empty e.code, so fall back to e.key.
const codeOf = e => e.code || (e.key === ' ' ? 'Space' : 'Key' + e.key.toUpperCase());
addEventListener('keydown', e => {
  keys.add(codeOf(e));
  if (codeOf(e) === 'Space') e.preventDefault();
});
addEventListener('keyup', e => keys.delete(codeOf(e)));
addEventListener('blur', () => keys.clear());

addEventListener('mousemove', e => {
  if (document.pointerLockElement !== renderer.domElement) return;
  player.yaw -= e.movementX * 0.0022;
  player.pitch = THREE.MathUtils.clamp(player.pitch - e.movementY * 0.0022, -1.45, 1.45);
});
addEventListener('mousedown', e => {
  if (mode !== 'play') return;
  if (e.button === 0) shoot('blue');
  else if (e.button === 2) shoot('orange');
});
addEventListener('contextmenu', e => e.preventDefault());

overlay.addEventListener('click', () => {
  if (mode === 'demo') startGame();
  else if (mode === 'paused') resumeGame();
});
document.getElementById('restart').addEventListener('click', startGame);
document.addEventListener('pointerlockchange', () => {
  if (document.pointerLockElement === renderer.domElement) return;
  if (mode === 'play') {
    mode = 'paused';
    showOverlay(true, 'Paused: click to resume');
  }
});
addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  syncSize();
});

// ---------- start ----------
resetDemo();
placePortal(portals.blue, V(7, 1.2, 0), findPanel(V(7, 1.2, 0)));
placePortal(portals.orange, V(0.8, 4.2, -7), findPanel(V(0.8, 4.2, -7)));
syncCamera();

let last = performance.now(), time = 0;
function frame(now) {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;
  time += dt;
  if (mode === 'demo') update(demoInput(dt), dt);
  else if (mode === 'play') update(liveInput(), dt);
  checkWin();
  player.viewLift *= Math.exp(-dt * 12);
  syncCamera();
  updateFx(dt);
  for (const P of PORTAL_LIST) P.material.uniforms.time.value = time;
  renderFrame();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// Read-only handle for automated testing.
window.chamber = {
  player,
  portals,
  get mode() { return mode; },
  shoot,
  setLook(yaw, pitch) { player.yaw = yaw; player.pitch = pitch; },
};
</script>
</body>
</html>

Same prompt, other models

2 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.