What you add

So far the rocks fly straight through the jet. Now a hit counts: the ship disappears, the rocks keep drifting in the background, and an overlay says GAME OVER with the time you survived. PLAY AGAIN, Enter or Space starts a new round, and every round begins with a big 3-2-1 countdown during which you can fly but no rocks come.

  • A game state with three values.
  • A collision test that is fair: near misses past a wing tip do not count, a rock through a wing does.
  • An HTML overlay for GAME OVER, and a countdown.
  • A restart that resets values, without starting anything new.
  • TIME in the HUD, as m:ss.

This is the first step where a mistake can look like a mystery: a game that ends for no visible reason, or one that suddenly runs twice as fast. If that happens, Debugging with AI explains how to describe the problem so the AI can find it, and when to stop asking for fixes and go back to your saved version.

Before you start

Save the Pen and copy your current script.js into a text file (or fork the Pen) before you send the prompt; this step changes a lot of code at once. Saving Versions and Going Back comes right after this page and explains the options. Then copy all three files of your Pen; they go at the end of the prompt.

The prompt also changes the HTML and the CSS (the overlay and the countdown), so paste back all three files from the answer, not only the JavaScript. If you use the optional pixel-font line from step 1, make sure it is still the first line of style.css:

@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap");

The prompt

Copy the whole prompt and replace the last line with the contents of your three files.

Here is my CodePen game so far (CodePen 2.0, three files, Three.js through
`import * as THREE from "three";`, WebGLRenderer, THREE.Timer for delta
time). Change it as described below and give me all three files complete
again. Keep everything that already works.

Step 4: collisions, game over and restart.

- Give the game a state: "countdown", "playing" or "over".
- Collision: describe the jet as five small spheres (nose, body, tail, left
  wing, right wing) relative to the ship. Treat a rock as a sphere a bit
  smaller than its size (rocks are lumpy). Because rocks are fast, check the
  rock's whole path during the frame (from its old to its new position)
  against each ship sphere: the shortest distance from a sphere's center to
  that line segment must be less than the two radii together.
- A hit ends the game: hide the ship, keep the rocks drifting in the
  background, and show an HTML overlay with "GAME OVER", the time survived
  (as m:ss), a "PLAY AGAIN" button and "OR PRESS ENTER". Use textContent,
  not innerHTML, and no alert().
- Restart with the button, Enter or Space, but only when the game is over:
  remove all rocks, put the ship back at its start (the lower middle),
  reset the time.
- At the start and after every restart: a big 3-2-1 countdown in the middle
  of the screen. During the countdown the ship can fly but no new rocks
  come; after it, the game is "playing".
- Show TIME m:ss in the HUD.
- Style the overlay and countdown in the retro style of the HUD (pixel
  font, cyan button with a 2px border, amber glowing title). Remember that
  display: grid beats the hidden attribute, so add [hidden] { display: none }.

Keep the pure logic (the segment distance, the swept hit test, the
countdown label, the time format) in small functions without Three.js or
the DOM in the rules section. Comment for a beginner.

[paste index.html, style.css and script.js here]

The prompt describes the collision test in plain words instead of just asking for "collision detection". Without that, many answers compare only where the rock is at the end of each frame, which misses fast rocks (see the swept test below). It also names two traps in advance: innerHTML and alert(), and a CSS rule that silently beats the hidden attribute.

What you should see

  • A big amber 3, 2, 1 at the start, then rocks begin to come.
  • TIME counts up at the top right.
  • Fly into a rock (or let one hit you): GAME OVER appears with your time.
  • Rocks keep drifting behind the GAME OVER screen; the time stops.
  • PLAY AGAIN, Enter and Space each start a new round with a new countdown and no rocks left over.
  • Enter or Space while playing does nothing.
  • Near misses past a wing tip do not end the game; a rock through a wing does.

If it goes wrong

For anything not on this list, start with the console and the approach on Debugging with AI.

  • Rocks fly through the jet without a hit. The test only compares the end positions of each frame. Ask for the swept test: the shortest distance from the ship sphere to the segment the rock moved along.
  • A hit happens when the rock is clearly beside the ship. The rock radius or the ship spheres are too big. Ask the AI to print them, then make the rock sphere smaller (a share like 0.85 of its size).
  • The overlay never disappears. CSS display: grid beats the hidden attribute. Add .overlay[hidden] { display: none; }.
  • After a restart, two games seem to run at once (double speed). The code starts a second animation loop, for example a restart that calls requestAnimationFrame(animate) again or runs the setup code (and a new renderer) a second time. There must be one loop, started once; restart only resets values.
  • Game over right after a restart. Old rocks were not removed. Restart must switch every rock off.

The reference version

This is one possible result of step 4: the tested reference version. Play runs it right here. The code below it is the three panels to paste into a Pen: the HTML goes inside <body>, then the CSS and the JavaScript. This frame is sandboxed, so a few things differ from CodePen: it has no network, so it supplies the pixel font itself instead of loading it; it has no storage; and fullscreen is hidden.

Click in the game once before you use the keys; the same goes for the CodePen preview. The game needs WebGL 2 (about 96% of browsers in use support it, according to caniuse); without it the frame stays blank. With the bare "three" import, CodePen 2.0 adds the latest Three.js version itself, which may be newer than the one this reference was built against: r186 (three@0.186.1), as of October 2026.

<canvas id="game"></canvas>
<div class="hud">
  <span class="title">RETRO JET</span>
  <span>TIME <span id="time">0:00</span></span>
</div>
<p class="hint">MOVE THE MOUSE (OR ARROWS / WASD) TO FLY</p>
<p id="countdown" class="countdown" hidden>3</p>
<div id="overlay" class="overlay" hidden>
  <h2 id="overlay-title">GAME OVER</h2>
  <p id="overlay-text"></p>
  <button id="overlay-button" type="button">PLAY AGAIN</button>
  <p class="small">OR PRESS ENTER</p>
</div>
/* The colors of the game, in one place. script.js uses the same values. */
:root {
  --space: #0a1220;
  --hull: #e8e2d0;
  --teal: #1f7a8c;
  --cyan: #7fe3f5;
  --cyan-deep: #2fa8c0;
  --ice: #d8f7ff;
  --copper: #b87333;
  --amber: #ffc890;
  --flame: #ff7a1a;
  --metal: #2e3036;
  /* A pixel font if you add it (see the step text); otherwise Courier New. */
  --font: "Press Start 2P", "Courier New", monospace;
}

/* The game fills the whole preview; nothing scrolls. */
html,
body {
  height: 100%;
  margin: 0;
  overflow: hidden;
  background: var(--space);
}

#game {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
  touch-action: none; /* a finger drags the ship instead of the page */
}

/* HUD: small pixel-style text on top of the game. */
.hud {
  position: fixed;
  inset: 0 0 auto 0;
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 12px 16px;
  font: 11px/1.6 var(--font);
  letter-spacing: 0.1em;
  color: var(--ice);
  pointer-events: none; /* clicks go through to the game */
}

.hud .title {
  color: var(--cyan);
}

/* A short help line at the bottom. */
.hint {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 14px;
  margin: 0;
  text-align: center;
  font: 9px/1.6 var(--font);
  letter-spacing: 0.1em;
  color: var(--cyan-deep);
  pointer-events: none;
}

/* The big 3-2-1 in the middle of the screen. */
.countdown {
  position: fixed;
  inset: 0;
  display: grid;
  place-content: center;
  margin: 0;
  font: 64px/1 var(--font);
  color: var(--amber);
  text-shadow: 0 0 18px var(--flame);
  pointer-events: none;
}

/* The overlay covers the game for GAME OVER (and later PAUSED). */
.overlay {
  position: fixed;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 16px;
  padding: 16px;
  text-align: center;
  font: 11px/1.8 var(--font);
  letter-spacing: 0.08em;
  color: var(--ice);
  background: rgb(10 18 32 / 0.72);
}

/* display: grid would beat the hidden attribute, so say it again. */
.overlay[hidden],
.countdown[hidden] {
  display: none;
}

.overlay h2 {
  margin: 0;
  font: 28px/1.2 var(--font);
  color: var(--amber);
  text-shadow: 0 0 14px var(--flame);
}

.overlay p {
  margin: 0;
}

.overlay button {
  padding: 10px 18px;
  font: 11px/1 var(--font);
  letter-spacing: 0.1em;
  color: var(--cyan);
  background: transparent;
  border: 2px solid var(--cyan);
  cursor: pointer;
}

.overlay button:hover,
.overlay button:focus-visible {
  color: var(--space);
  background: var(--cyan);
}

.overlay .small {
  font-size: 9px;
  color: var(--cyan-deep);
}
import * as THREE from "three";

// ---- GAME RULES (pure functions: no Three.js, no DOM) ----
// Plain numbers and small functions that only do math, so they are easy
// to read, to change and to test.

// The camera sits behind and a little above the action, looking ahead.
const CAMERA_POSITION = { x: 0, y: 1.4, z: 8 };
const CAMERA_TARGET = { x: 0, y: 0.6, z: -12 };
const FIELD_OF_VIEW = 60; // degrees, top to bottom
const SPACE_COLOR = 0x0a1220; // same as --space in the CSS

const STAR_COUNT = 1800;
const STAR_FAR = -190; // stars start this far ahead (negative z = ahead)...
const STAR_NEAR = 12; // ...and wrap around once they pass the camera
const STAR_MIN_RADIUS = 3; // an empty tube around the flight path
const STAR_MAX_RADIUS = 70;
const STAR_SPEED = 30; // units per second toward the camera
const MAX_STEP = 0.05; // longest time step we accept, in seconds

// The ship's colors (the same values as the CSS custom properties).
const PALETTE = {
  hull: 0xe8e2d0,
  teal: 0x1f7a8c,
  cyan: 0x7fe3f5,
  cyanDeep: 0x2fa8c0,
  ice: 0xd8f7ff,
  copper: 0xb87333,
  amber: 0xffc890,
  flame: 0xff7a1a,
  metal: 0x2e3036,
};
const SHIP_FOLLOW = 6; // how quickly the ship catches up (higher = snappier)
const KEY_SPEED = 9; // units per second when steering with the keys
const WING_MARGIN = 1.9; // keep the wing tips on screen
const ROLL = 0.09; // radians of roll per unit/second of sideways speed
const PITCH = 0.05; // radians of pitch per unit/second of climb
const MAX_TILT = 0.7; // never tilt more than this (radians)

const FOG_NEAR = 40; // fog starts here (distance from the camera)...
const FOG_FAR = 110; // ...and hides everything beyond this
const ROCK_SHAPES = 16; // lumpy shapes, made once and shared
const ROCK_POOL = 30; // rock meshes, made once and reused
const ROCK_MAX = 14; // rocks in flight at most
const SPAWN_Z = -95; // rocks appear this far ahead
const DESPAWN_Z = 12; // and are recycled once they pass the camera
const SPAWN_EVERY = 0.7; // seconds between new rocks
const ROCK_SPEED = 24; // units per second
const AIM_CHANCE = 0.3; // share of rocks aimed straight at the ship
const ROCK_MIN_RADIUS = 0.7;
const ROCK_MAX_RADIUS = 1.8;
const ROCK_BUMPINESS = 0.28; // 0 = a smooth ball, 0.5 = very lumpy
const SLATE = [0x5b6472, 0x4a525e, 0x6c7684, 0x3e444e, 0x7a8390]; // rock shades

// Hit spheres along the ship (x, y, z from the ship's middle, radius r).
// A few spheres follow the jet's shape much better than one big one.
const SHIP_SPHERES = [
  { x: 0, y: 0, z: -1.1, r: 0.3 }, // nose
  { x: 0, y: 0, z: 0, r: 0.45 }, // body
  { x: 0, y: 0, z: 0.9, r: 0.35 }, // tail
  { x: -1.1, y: -0.05, z: 0.2, r: 0.35 }, // left wing
  { x: 1.1, y: -0.05, z: 0.2, r: 0.35 }, // right wing
];
const ROCK_HIT_SHARE = 0.85; // rocks are lumpy: use a bit less than the radius
const COUNTDOWN = 3; // seconds of 3-2-1 before rocks come
// Where the ship starts, as a pointer position (-1 to 1, see pointerTarget):
// x 0 is the middle, y -0.5 is halfway down to the bottom (the lower middle).
const START = { x: 0, y: -0.5 };

// One star [x, y, z] somewhere in a long tube around the flight path.
// random() returns a number from 0 to 1.
function randomStar(random) {
  const angle = random() * Math.PI * 2;
  // Math.sqrt spreads the stars evenly over the ring.
  const spread = Math.sqrt(random()) * (STAR_MAX_RADIUS - STAR_MIN_RADIUS);
  const radius = STAR_MIN_RADIUS + spread;
  const z = STAR_FAR + random() * (STAR_NEAR - STAR_FAR);
  return [Math.cos(angle) * radius, Math.sin(angle) * radius, z];
}

// Move a star toward the camera. Once it is behind the camera, send it
// back to the far end: the same 1800 stars stream past forever.
function streamStar(z, dt, speed) {
  const next = z + speed * dt;
  return next > STAR_NEAR ? next - (STAR_NEAR - STAR_FAR) : next;
}

// Seconds since the last frame, never negative and never a huge jump.
function clampStep(seconds) {
  return Math.min(Math.max(seconds, 0), MAX_STEP);
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

// Where the ship may fly: what the camera sees at the ship's depth (z = 0),
// minus a margin, so the wings stay on screen.
function shipBounds(aspect) {
  const distance = CAMERA_POSITION.z; // from the camera to z = 0
  const halfHeight = Math.tan((FIELD_OF_VIEW * Math.PI) / 360) * distance;
  const halfWidth = halfHeight * aspect;
  // The camera looks a little down, so the middle of the view at z = 0
  // is a little above y = 0.
  const slope =
    (CAMERA_TARGET.y - CAMERA_POSITION.y) /
    (CAMERA_TARGET.z - CAMERA_POSITION.z);
  const centerY = CAMERA_POSITION.y - slope * distance;
  return {
    halfWidth,
    halfHeight,
    centerY,
    minX: -halfWidth + WING_MARGIN,
    maxX: halfWidth - WING_MARGIN,
    minY: centerY - halfHeight + 0.9,
    maxY: centerY + halfHeight - 1.3,
  };
}

// The pointer, in "normalized device coordinates" (-1 to 1, left to right
// and bottom to top), as a point at the ship's depth inside the bounds.
function pointerTarget(ndcX, ndcY, bounds) {
  return {
    x: clamp(ndcX * bounds.halfWidth, bounds.minX, bounds.maxX),
    y: clamp(
      bounds.centerY + ndcY * bounds.halfHeight,
      bounds.minY,
      bounds.maxY,
    ),
  };
}

// The ship's starting point: where a pointer at START would put it.
function startTarget(bounds) {
  return pointerTarget(START.x, START.y, bounds);
}

// Ease toward a target: every second the gap shrinks by the same share,
// so it feels the same at 60 and at 144 frames per second.
function follow(current, target, rate, dt) {
  return target + (current - target) * Math.exp(-rate * dt);
}

// Speed -> a tilt angle (roll into a turn, nose up when climbing).
function tilt(speed, amount) {
  return clamp(speed * amount, -MAX_TILT, MAX_TILT);
}

// Held keys -> a direction: x and y are -1, 0 or 1.
function keyDirection(held) {
  const x =
    (held.has("ArrowRight") || held.has("KeyD") ? 1 : 0) -
    (held.has("ArrowLeft") || held.has("KeyA") ? 1 : 0);
  const y =
    (held.has("ArrowUp") || held.has("KeyW") ? 1 : 0) -
    (held.has("ArrowDown") || held.has("KeyS") ? 1 : 0);
  return { x, y };
}

// A velocity {x, y, z} from one point toward another, at a given speed.
function aimVelocity(from, to, speed) {
  const dx = to.x - from.x;
  const dy = to.y - from.y;
  const dz = to.z - from.z;
  const length = Math.hypot(dx, dy, dz) || 1; // never divide by zero
  return {
    x: (dx / length) * speed,
    y: (dy / length) * speed,
    z: (dz / length) * speed,
  };
}

// Where a new rock starts (far ahead, spread over the view) and how it
// flies: aimed straight at the ship, or at a random spot near it.
function rockPath(random, aimed, shipAt, bounds, speed) {
  const start = {
    x: (random() * 2 - 1) * bounds.halfWidth * 6,
    y: bounds.centerY + (random() * 2 - 1) * bounds.halfHeight * 6,
    z: SPAWN_Z,
  };
  const goal = aimed
    ? { x: shipAt.x, y: shipAt.y, z: 0 }
    : {
        x: bounds.minX - 2 + random() * (bounds.maxX - bounds.minX + 4),
        y: bounds.minY - 1 + random() * (bounds.maxY - bounds.minY + 2),
        z: 0,
      };
  return { start, velocity: aimVelocity(start, goal, speed) };
}

// Makes a smooth shape lumpy: every corner moves in or out a little.
// Corners at the same spot (shared by several triangles) move together,
// so the rock gets no cracks. Returns a new array.
function roughen(positions, random, amount) {
  const result = Float32Array.from(positions);
  const pushes = new Map();
  for (let i = 0; i < result.length; i += 3) {
    const key = [0, 1, 2].map((k) => result[i + k].toFixed(3)).join();
    if (!pushes.has(key)) pushes.set(key, 1 + (random() * 2 - 1) * amount);
    for (let k = 0; k < 3; k++) result[i + k] *= pushes.get(key);
  }
  return result;
}

// 0xRRGGBB -> [r, g, b], each from 0 to 1.
function hexToRgb(hex) {
  return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((v) => v / 255);
}

// One slate shade per triangle: 3 corners x 3 numbers (r, g, b) each.
function faceShades(faceCount, random) {
  const colors = new Float32Array(faceCount * 9);
  for (let face = 0; face < faceCount; face++) {
    const rgb = hexToRgb(SLATE[Math.floor(random() * SLATE.length)]);
    for (let corner = 0; corner < 3; corner++)
      colors.set(rgb, face * 9 + corner * 3);
  }
  return colors;
}

// The shortest distance from point c to the line from a to b.
function segmentDistance(a, b, c) {
  const ab = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z };
  const lengthSquared = ab.x * ab.x + ab.y * ab.y + ab.z * ab.z;
  const along = (c.x - a.x) * ab.x + (c.y - a.y) * ab.y + (c.z - a.z) * ab.z;
  // t = 0 at a, 1 at b: the point on the line closest to c.
  const t = lengthSquared === 0 ? 0 : clamp(along / lengthSquared, 0, 1);
  return Math.hypot(
    a.x + ab.x * t - c.x,
    a.y + ab.y * t - c.y,
    a.z + ab.z * t - c.z,
  );
}

// Did a sphere moving from `from` to `to` in this frame touch one of the
// ship's spheres? Checking the whole path (a "swept sphere") means a fast
// rock cannot jump over the ship between two frames.
function sweptHit(from, to, radius, shipAt, spheres) {
  return spheres.some((sphere) => {
    const center = {
      x: shipAt.x + sphere.x,
      y: shipAt.y + sphere.y,
      z: shipAt.z + sphere.z,
    };
    return segmentDistance(from, to, center) < radius + sphere.r;
  });
}

// What the countdown shows with this many seconds left: "3", "2", "1".
function countdownLabel(secondsLeft) {
  return String(Math.max(1, Math.ceil(secondsLeft)));
}

// 75.4 seconds -> "1:15"
function formatTime(seconds) {
  const whole = Math.floor(seconds);
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
}
// ---- END OF GAME RULES ----

// ---- Renderer, scene and camera ----
const canvas = document.querySelector("#game");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

const scene = new THREE.Scene();
scene.background = new THREE.Color(SPACE_COLOR);
// Fog in the color of space: far rocks loom out of the dark.
scene.fog = new THREE.Fog(SPACE_COLOR, FOG_NEAR, FOG_FAR);
const camera = new THREE.PerspectiveCamera(FIELD_OF_VIEW, 1, 0.1, 400);
camera.position.set(CAMERA_POSITION.x, CAMERA_POSITION.y, CAMERA_POSITION.z);
camera.lookAt(CAMERA_TARGET.x, CAMERA_TARGET.y, CAMERA_TARGET.z);

// Lights: soft light everywhere, a white "sun", and a cyan rim light
// from ahead that outlines the ship.
scene.add(new THREE.AmbientLight(0xbfd8ff, 0.5));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(4, 6, 6);
const rim = new THREE.DirectionalLight(PALETTE.cyan, 1.2);
rim.position.set(-5, 2, -8);
scene.add(sun, rim);

// ---- The starfield: 1800 points in one geometry ----
const starPositions = new Float32Array(STAR_COUNT * 3);
const starColors = new Float32Array(STAR_COUNT * 3);
for (let i = 0; i < STAR_COUNT; i++) {
  starPositions.set(randomStar(Math.random), i * 3);
  const shade = 0.4 + Math.random() * 0.6; // some stars dimmer than others
  starColors.set([shade * 0.85, shade * 0.97, shade], i * 3); // icy white
}
const starGeometry = new THREE.BufferGeometry();
const attribute = (array) => new THREE.BufferAttribute(array, 3);
starGeometry.setAttribute("position", attribute(starPositions));
starGeometry.setAttribute("color", attribute(starColors));
// sizeAttenuation: false keeps every star 2 pixels, like an arcade screen.
const starMaterial = new THREE.PointsMaterial({ size: 2, vertexColors: true });
starMaterial.sizeAttenuation = false;
starMaterial.fog = false; // stars are far beyond the fog, but should show
const stars = new THREE.Points(starGeometry, starMaterial);
stars.frustumCulled = false; // the stars move, so always draw them
scene.add(stars);

// ---- The ship: the "retro jet", built from simple shapes ----
// The nose points ahead (-z). Every part is a Mesh added to one Group, so
// moving or turning the group moves the whole ship.
function buildShip() {
  const ship = new THREE.Group();
  ship.name = "ship";
  // flatShading shows every face as one flat color: the low-poly look.
  const look = (color, extra) =>
    new THREE.MeshStandardMaterial({ color, flatShading: true, ...extra });
  const hull = look(PALETTE.hull, { roughness: 0.6 });
  const teal = look(PALETTE.teal, { roughness: 0.5 });
  const copper = look(PALETTE.copper, { metalness: 0.6, roughness: 0.35 });
  const metal = look(PALETTE.metal, { metalness: 0.5, side: THREE.DoubleSide });
  const neon = new THREE.MeshBasicMaterial({ color: PALETTE.cyan }); // no light needed: it glows
  const add = (geometry, material, x = 0, y = 0, z = 0) => {
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(x, y, z);
    ship.add(mesh);
    return mesh;
  };

  // Body: a side profile (radius at each point along the length), turned
  // around an axis like wood on a lathe.
  const profile = [
    [0, -1.2],
    [0.3, -1.15],
    [0.42, -0.7],
    [0.44, 0.1],
    [0.36, 0.9],
    [0.2, 1.35],
    [0, 1.6],
  ].map(([radius, along]) => new THREE.Vector2(radius, along));
  const body = new THREE.LatheGeometry(profile, 12);
  body.rotateX(-Math.PI / 2); // the lathe axis was y; now the nose points ahead
  add(body, hull);

  const nose = new THREE.ConeGeometry(0.22, 0.45, 12);
  nose.rotateX(-Math.PI / 2);
  add(nose, teal, 0, 0, -1.5);

  // Copper rings around the body, and neon lines along its sides.
  for (const z of [-0.55, 0.5])
    add(new THREE.TorusGeometry(0.43, 0.035, 6, 24), copper, 0, 0, z);
  for (const side of [-1, 1])
    add(new THREE.BoxGeometry(0.03, 0.03, 1.3), neon, side * 0.41, 0.1, -0.05);

  // Cockpit: half a sphere, stretched, made of see-through glass.
  const glass = look(PALETTE.ice, {
    transparent: true,
    opacity: 0.6,
    roughness: 0.1,
    emissive: PALETTE.cyanDeep,
    emissiveIntensity: 0.25,
  });
  const canopy = add(
    new THREE.SphereGeometry(0.24, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2),
    glass,
    0,
    0.3,
    -0.45,
  );
  canopy.scale.set(1, 0.8, 2.2);

  // Swept wings: a flat outline (Shape), given thickness (Extrude).
  for (const side of [-1, 1]) {
    const outline = new THREE.Shape();
    outline.moveTo(side * 0.3, 0.5);
    outline.lineTo(side * 1.7, -0.45);
    outline.lineTo(side * 1.7, -0.75);
    outline.lineTo(side * 0.3, -0.6);
    outline.closePath();
    const wing = new THREE.ExtrudeGeometry(outline, {
      depth: 0.07,
      bevelEnabled: false,
    });
    wing.rotateX(-Math.PI / 2); // lay it flat, front edge ahead
    add(wing, teal, 0, -0.08, 0);
    // A neon line along the front edge of the wing.
    const edge = add(
      new THREE.BoxGeometry(1.69, 0.03, 0.03),
      neon,
      side * 1.0,
      0,
      -0.03,
    );
    edge.rotation.y = -side * Math.atan2(0.95, 1.4);
    // A small gun barrel on the wing tip, with a copper tip.
    const barrel = new THREE.CylinderGeometry(0.04, 0.04, 0.6, 6);
    barrel.rotateX(Math.PI / 2);
    add(barrel, metal, side * 1.7, -0.02, 0.2);
    add(
      new THREE.BoxGeometry(0.1, 0.1, 0.08),
      copper,
      side * 1.7,
      -0.02,
      -0.12,
    );
  }

  // Tail fin: another flat outline, standing up.
  const finOutline = new THREE.Shape();
  finOutline.moveTo(0, 0);
  finOutline.lineTo(0.75, 0);
  finOutline.lineTo(0.85, 0.6);
  finOutline.lineTo(0.55, 0.6);
  finOutline.closePath();
  const fin = new THREE.ExtrudeGeometry(finOutline, {
    depth: 0.05,
    bevelEnabled: false,
  });
  fin.rotateY(-Math.PI / 2); // stand it up, pointing back
  add(fin, teal, 0.025, 0.3, 0.3);

  // Engine: an open bell at the back, and an orange flame. Additive
  // blending adds its color to what is behind it, so it seems to glow.
  const bell = new THREE.CylinderGeometry(0.3, 0.2, 0.35, 12, 1, true);
  bell.rotateX(Math.PI / 2);
  add(bell, metal, 0, 0, 1.25);
  const flameShape = new THREE.ConeGeometry(0.18, 0.9, 10);
  flameShape.rotateX(Math.PI / 2); // tip pointing back
  flameShape.translate(0, 0, 0.45); // wide end at z = 0, so it grows backward
  const flameLook = new THREE.MeshBasicMaterial({
    color: PALETTE.flame,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });
  const flame = add(flameShape, flameLook, 0, 0, 1.4);
  flame.name = "flame";
  return ship;
}
const ship = buildShip();
const flame = ship.getObjectByName("flame");
scene.add(ship);
ship.position.y = startTarget(shipBounds(1)).y;

// ---- Steering: the ship follows a target point ----
// The mouse (or a finger) sets the target; the arrow keys or WASD move it.
let bounds = shipBounds(1);
let target = startTarget(bounds); // until the first mouse or key input
let velocity = { x: 0, y: 0 }; // only used to tilt the ship

window.addEventListener("pointermove", (event) => {
  const ndcX = (event.clientX / window.innerWidth) * 2 - 1;
  const ndcY = -(event.clientY / window.innerHeight) * 2 + 1; // y is up in 3D
  target = pointerTarget(ndcX, ndcY, bounds);
});

// e.code names the physical key ("KeyW"), so WASD also works on an AZERTY
// keyboard. Arrow keys and Space would scroll the page, so we stop that.
const held = new Set();
window.addEventListener("keydown", (event) => {
  if (event.code.startsWith("Arrow") || event.code === "Space") {
    event.preventDefault();
  }
  held.add(event.code);
  if (event.code === "Enter" || event.code === "Space") restart();
});
window.addEventListener("keyup", (event) => held.delete(event.code));
// When the game loses focus we never hear the keyup, so forget all keys.
window.addEventListener("blur", () => held.clear());

// ---- Pools: objects made once, then switched on and off ----
// Making new meshes all the time would fill up memory. Instead, every
// rock comes from a pool and goes back to it when it is done.
function makePool(count, create) {
  return Array.from({ length: count }, (_, i) => {
    const mesh = create(i);
    mesh.visible = false;
    scene.add(mesh);
    return { mesh, active: false };
  });
}
const freeOne = (pool) => pool.find((item) => !item.active);
const activeIn = (pool) => pool.filter((item) => item.active);

function switchOn(item, at, velocity) {
  item.active = true;
  item.velocity = velocity;
  item.mesh.position.set(at.x, at.y, at.z);
  item.mesh.visible = true;
}

function switchOff(item) {
  item.active = false;
  item.mesh.visible = false;
}

// Move by velocity x time.
function drift(item, dt) {
  item.mesh.position.x += item.velocity.x * dt;
  item.mesh.position.y += item.velocity.y * dt;
  item.mesh.position.z += item.velocity.z * dt;
}

// ---- Rocks: lumpy, flat-shaded, in shades of slate ----
const rockShapes = Array.from({ length: ROCK_SHAPES }, () => {
  const geometry = new THREE.IcosahedronGeometry(1, 1); // 80 triangles
  const position = geometry.attributes.position;
  position.array.set(roughen(position.array, Math.random, ROCK_BUMPINESS));
  const shades = faceShades(position.count / 3, Math.random);
  geometry.setAttribute("color", new THREE.BufferAttribute(shades, 3));
  geometry.computeVertexNormals();
  return geometry;
});
const rockLook = new THREE.MeshStandardMaterial({
  vertexColors: true, // each triangle has its own shade
  flatShading: true,
  roughness: 0.9,
});

const rocks = makePool(ROCK_POOL, (i) => {
  const rock = new THREE.Mesh(rockShapes[i % ROCK_SHAPES], rockLook);
  rock.name = "rock";
  return rock;
});

function launchRock(rock) {
  const aimed = Math.random() < AIM_CHANCE;
  const speed = ROCK_SPEED * (0.8 + Math.random() * 0.4);
  const path = rockPath(Math.random, aimed, ship.position, bounds, speed);
  switchOn(rock, path.start, path.velocity);
  rock.radius =
    ROCK_MIN_RADIUS + Math.random() * (ROCK_MAX_RADIUS - ROCK_MIN_RADIUS);
  rock.mesh.scale.setScalar(rock.radius);
  rock.spin = { x: Math.random() - 0.5, y: Math.random() - 0.5 };
}

let spawnTimer = 0; // seconds until the next rock

// ---- Game state: "countdown", "playing" or "over" ----
let state = "countdown";
let countdownLeft = COUNTDOWN; // seconds
let survived = 0; // seconds

// ---- HUD and overlay: plain HTML on top of the canvas ----
// textContent (not innerHTML) puts text in, never markup.
const $ = (selector) => document.querySelector(selector);
const timeText = $("#time");
const countdownText = $("#countdown");
const overlay = $("#overlay");

function showOverlay(title, text, buttonLabel) {
  $("#overlay-title").textContent = title;
  $("#overlay-text").textContent = text;
  $("#overlay-button").textContent = buttonLabel;
  overlay.hidden = false;
}

function gameOver() {
  state = "over";
  ship.visible = false;
  showOverlay(
    "GAME OVER",
    `YOU SURVIVED ${formatTime(survived)}`,
    "PLAY AGAIN",
  );
}

// Back to the start: clear the rocks, ship at its start, count down.
function restart() {
  if (state !== "over") return;
  rocks.forEach(switchOff);
  target = startTarget(bounds);
  ship.position.set(target.x, target.y, 0);
  ship.visible = true;
  survived = 0;
  spawnTimer = 0;
  countdownLeft = COUNTDOWN;
  state = "countdown";
  overlay.hidden = true;
}
$("#overlay-button").addEventListener("click", restart);

// Did a rock hit the ship this frame? `from` is where it was before.
function rockHitsShip(rock, from) {
  const radius = rock.radius * ROCK_HIT_SHARE;
  return sweptHit(
    from,
    rock.mesh.position,
    radius,
    ship.position,
    SHIP_SPHERES,
  );
}

// ---- Fit the canvas to the window, also after a resize ----
function resize() {
  renderer.setSize(window.innerWidth, window.innerHeight);
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix(); // tell the camera about its new shape
  bounds = shipBounds(camera.aspect);
}
window.addEventListener("resize", resize);
resize();

// ---- The animation loop ----
// THREE.Timer measures the time between frames. connect(document) makes it
// skip the time a background tab was hidden.
const timer = new THREE.Timer();
timer.connect(document);

function updateStars(dt) {
  for (let i = 0; i < STAR_COUNT; i++) {
    const z = i * 3 + 2; // x, y, z: z is every third number, from index 2
    starPositions[z] = streamStar(starPositions[z], dt, STAR_SPEED);
  }
  // Tell Three.js the numbers changed, so it sends them to the GPU again.
  starGeometry.attributes.position.needsUpdate = true;
}

function updateShip(dt) {
  // Keys move the target; the target always stays inside the bounds.
  const keys = keyDirection(held);
  target = {
    x: clamp(target.x + keys.x * KEY_SPEED * dt, bounds.minX, bounds.maxX),
    y: clamp(target.y + keys.y * KEY_SPEED * dt, bounds.minY, bounds.maxY),
  };
  // Glide toward the target, and remember how fast we moved.
  const before = { x: ship.position.x, y: ship.position.y };
  ship.position.x = follow(ship.position.x, target.x, SHIP_FOLLOW, dt);
  ship.position.y = follow(ship.position.y, target.y, SHIP_FOLLOW, dt);
  if (dt > 0) {
    velocity = {
      x: (ship.position.x - before.x) / dt,
      y: (ship.position.y - before.y) / dt,
    };
  }
  // Roll into sideways moves, pitch the nose up when climbing.
  ship.rotation.z = -tilt(velocity.x, ROLL);
  ship.rotation.x = tilt(velocity.y, PITCH);
  // The flame flickers: a slightly different length every frame.
  flame.scale.z = 0.8 + Math.random() * 0.4;
}

function updateRocks(dt) {
  spawnTimer -= dt;
  const launching = state === "playing"; // no new rocks during 3-2-1
  if (launching && spawnTimer <= 0 && activeIn(rocks).length < ROCK_MAX) {
    spawnTimer = SPAWN_EVERY;
    launchRock(freeOne(rocks));
  }
  for (const rock of activeIn(rocks)) {
    const from = rock.mesh.position.clone();
    drift(rock, dt);
    if (state === "playing" && rockHitsShip(rock, from)) gameOver();
    rock.mesh.rotation.x += rock.spin.x * dt;
    rock.mesh.rotation.y += rock.spin.y * dt;
    // Past the camera? Back to the pool.
    if (rock.mesh.position.z > DESPAWN_Z) switchOff(rock);
  }
}

renderer.setAnimationLoop((time) => {
  timer.update(time);
  const dt = clampStep(timer.getDelta()); // seconds since the last frame

  // Speeds are "per second" times dt, so the game runs at the same pace
  // on a 60 Hz and a 144 Hz screen.
  updateStars(dt);
  if (state === "countdown") {
    countdownLeft -= dt;
    if (countdownLeft <= 0) state = "playing";
  }
  if (state === "playing") survived += dt;
  if (state !== "over") updateShip(dt);
  updateRocks(dt); // rocks keep drifting behind the GAME OVER screen
  timeText.textContent = formatTime(survived);
  countdownText.hidden = state !== "countdown";
  countdownText.textContent = countdownLabel(countdownLeft);

  renderer.render(scene, camera);
});

How it works

The excerpts below are copied from the reference script.js. Your version will use other names; look for the same ideas.

Game states

A game state is the mode a game is in, such as counting down, playing or game over, kept in one variable that decides which rules apply in each frame. Without it, every rule needs its own flags ("is the ship dead?", "has the countdown finished?"), and sooner or later two flags disagree. With it, the animation loop reads like a short description of the game:

  if (state === "countdown") {
    countdownLeft -= dt;
    if (countdownLeft <= 0) state = "playing";
  }
  if (state === "playing") survived += dt;
  if (state !== "over") updateShip(dt);
  updateRocks(dt); // rocks keep drifting behind the GAME OVER screen

The reference uses a string and if statements; a switch on the state works just as well. Note what does not happen at game over: the loop keeps running, so the rocks still drift and the scene still renders. Only the updates that belong to playing stop. (Three.js can also stop the loop completely with renderer.setAnimationLoop(null), but then nothing moves behind the overlay.)

Collision detection

Collision detection is the check, usually once per frame, of whether two objects in a game touch or overlap. Real shapes are expensive to compare triangle by triangle, so games compare simple stand-in shapes. A bounding sphere is such a stand-in: a sphere around (or roughly covering) an object, so two objects touch when the distance between the centers is less than the two radii added together. That distance is the Euclidean distance in three dimensions.

One sphere around the whole jet would be unfair: it would include the empty space between the wings. The reference uses five small ones, and a rock sphere a bit smaller than the rock, because rocks are lumpy:

const SHIP_SPHERES = [
  { x: 0, y: 0, z: -1.1, r: 0.3 }, // nose
  { x: 0, y: 0, z: 0, r: 0.45 }, // body
  { x: 0, y: 0, z: 0.9, r: 0.35 }, // tail
  { x: -1.1, y: -0.05, z: 0.2, r: 0.35 }, // left wing
  { x: 1.1, y: -0.05, z: 0.2, r: 0.35 }, // right wing
];
const ROCK_HIT_SHARE = 0.85; // rocks are lumpy: use a bit less than the radius

These numbers decide whether the game feels fair, and they are exactly the kind of thing to tune by playing, not by asking the AI to guess. The spheres do not tilt with the ship when it rolls; that keeps the test simple and is close enough at these sizes.

The swept test

A rock moves about 24 units per second (19 to 29): about 0.4 units per frame at 60 frames per second, and up to about 1.4 units after a slow frame (the delta time is clamped at 0.05 seconds). A rock that grazes a ship sphere crosses it along a short stretch, and a test that compares only where the rock is at the end of each frame can step right over it. The faster the rocks (step 6 speeds them up), the more hits slip through that way.

A swept collision test checks the whole path an object moved along during one frame, not only its new position: here, the shortest distance from each ship sphere's center to the line segment between the rock's old and new position must be less than the two radii together.

Two ways to test a fast rock against a ship sphere. On the left, only the end positions are compared: before the frame the rock is left of the ship, after the frame it is right of it, neither overlaps the sphere, so the test reports a miss although the rock passed through. On the right, the swept test draws the segment from the old to the new position; the shortest distance d from the sphere's center to that segment is smaller than the two radii together, so it reports a hit. End positions only ship sphere before after no overlap: "miss" Swept test ship sphere before after d d < r₁ + r₂: hit
A fast rock can jump past the ship between two frames; the swept test checks the path, not only the two ends.

Both functions are in the rules section at the top of the script, so they can be tested with plain numbers:

// The shortest distance from point c to the line from a to b.
function segmentDistance(a, b, c) {
  const ab = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z };
  const lengthSquared = ab.x * ab.x + ab.y * ab.y + ab.z * ab.z;
  const along = (c.x - a.x) * ab.x + (c.y - a.y) * ab.y + (c.z - a.z) * ab.z;
  // t = 0 at a, 1 at b: the point on the line closest to c.
  const t = lengthSquared === 0 ? 0 : clamp(along / lengthSquared, 0, 1);
  return Math.hypot(
    a.x + ab.x * t - c.x,
    a.y + ab.y * t - c.y,
    a.z + ab.z * t - c.z,
  );
}

// Did a sphere moving from `from` to `to` in this frame touch one of the
// ship's spheres? Checking the whole path (a "swept sphere") means a fast
// rock cannot jump over the ship between two frames.
function sweptHit(from, to, radius, shipAt, spheres) {
  return spheres.some((sphere) => {
    const center = {
      x: shipAt.x + sphere.x,
      y: shipAt.y + sphere.y,
      z: shipAt.z + sphere.z,
    };
    return segmentDistance(from, to, center) < radius + sphere.r;
  });
}

along is a dot product: it measures how far along the segment the point closest to the center lies. Clamping t between 0 and 1 keeps that point on the segment itself, not on the endless line through it. You do not need to follow every step to use the function, but you can check what it promises: a rock that jumps through the ship in one frame is caught, and one that passes beside it is not. Those two cases are exactly what the reference version's tests check.

A restart that only resets

A common bug in AI-written restarts is starting the game again: calling the setup code a second time, which can add a second renderer and animation loop and adds a second set of event listeners. Everything then happens twice per frame. The reference restart starts nothing; it puts values back:

function restart() {
  if (state !== "over") return;
  rocks.forEach(switchOff);
  target = startTarget(bounds);
  ship.position.set(target.x, target.y, 0);
  ship.visible = true;
  survived = 0;
  spawnTimer = 0;
  countdownLeft = COUNTDOWN;
  state = "countdown";
  overlay.hidden = true;
}

The first line is the state check that makes Enter and Space do nothing while you play. Switching every rock off is what prevents the game over right after a restart: the pool from step 3 makes this a one-liner.

The overlay

GAME OVER is plain HTML on top of the canvas, positioned with position: fixed and inset: 0 (see overlays with inset). The script fills it with textContent, which inserts text and never markup, and shows or hides it with the hidden attribute. Because the overlay's CSS sets display: grid, which would otherwise win over hidden, the stylesheet says it again:

.overlay[hidden],
.countdown[hidden] {
  display: none;
}