What you add

Until now the ship has flown through empty space. In this step something comes at it: gray, lumpy rocks that appear far ahead, grow as they approach and fly past. Some aim at the ship, so you already have to dodge, even though a hit does nothing yet; collisions come in step 4. The step is only about getting many objects to appear, move and disappear correctly, which is a problem of its own.

  • Fog in the background color, so far rocks are hidden.
  • Sixteen rock shapes made once: lumpy, faceted, in slate-gray shades.
  • A pool of about 30 rock meshes, reused for the whole game.
  • A new rock every 0.7 seconds, at most 14 in flight, 30% of them aimed at the ship.

The prompt is longer than the first two, because it says how to build the rocks, not only what they should look like. That is the lesson of Reading the Code You Got put into practice: when you know what to look for in the code (an object created every frame, a list that only grows), you can ask for the right structure up front instead of finding the problem later.

Before you start

Save the Pen and copy your current script.js into a text file (or fork the Pen), so you can go back if this step goes wrong. Saving Versions and Going Back explains the options on a free account. Then copy all three files of your Pen; they go at the end of the prompt.

Your step 2 does not have to match the reference version. The prompt describes the change in words, not by line numbers, so it works on top of your own ship. If you added the optional pixel-font line from step 1 at the top of style.css, it stays where it is:

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

Without that line the HUD falls back to Courier New, which works just as well.

The prompt

Copy the whole prompt and replace the last line with the contents of your three files. A new chat works well here, because the prompt carries the whole game with it.

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 3: rocks.

- Add fog in the background color (#0a1220), from 40 to 110 units, so rocks
  appear out of the dark. The stars must not be affected by the fog.
- Make 16 rock shapes once: an IcosahedronGeometry with each corner pushed
  in or out a little (corners at the same spot must move together, so
  there are no cracks), and one slate-gray shade per triangle as vertex
  colors. Use one shared MeshStandardMaterial with flatShading and
  vertexColors.
- Make a pool of about 30 rock meshes once, sharing these shapes. Do not
  create or delete meshes during the game: switch a rock on when it is
  launched and off when it is done.
- Launch a new rock every 0.7 seconds (at most 14 in flight), 95 units
  ahead, at a random spot spread over the view. About 30% fly straight at
  the ship's current position; the others fly toward a random point near
  the area where the ship can fly. Random size and slow random spin.
- A rock that has passed the camera goes back to the pool.
- Change the HUD text to "STEP 3 · ROCKS".

Keep the pure logic (the start point and direction of a rock, the
roughening of the shape, the colors per triangle) in small functions
without Three.js or the DOM, in the rules section at the top, and let
random() be passed in so I can test them with fixed numbers.
Comment for a beginner.

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

Three sentences do most of the work. "Do not create or delete meshes during the game" rules out the most common way generated game code gets slow. "Corners at the same spot must move together" prevents cracks in the rocks. And "let random() be passed in" keeps the rock logic testable: with a fixed sequence of numbers instead of Math.random, the same input always gives the same rock.

What you should see

  • Gray, lumpy, faceted rocks appear far ahead, faint at first, and grow as they come closer.
  • They come from all over the view, not only the middle, and they turn slowly.
  • Some of them come straight at the jet (they pass right through it for now).
  • Rocks keep coming for minutes: none get stuck, and they do not pile up.
  • The stars are still bright (not swallowed by the fog).

If it goes wrong

  • Rocks pop up close by instead of far away. The start z must be about −95 (ahead is negative z). Ask the AI to show you where the start point is computed.
  • The rocks have black cracks or holes. The corners were moved one by one. Corners that share a position must get the same push; ask the AI to change exactly that.
  • After 10 to 20 seconds no more rocks come. Rocks are never switched off, so the limit of 14 rocks in flight (ROCK_MAX) is soon reached. A rock must go back to the pool once its z is past the camera.
  • The game gets slower over time. The code creates new meshes or geometries for every rock. Ask: "Do you create anything new while the game runs? Reuse the pool instead."
  • The stars disappeared. PointsMaterial uses fog by default; the stars need fog: false.
  • Everything is one flat gray. The material needs vertexColors: true, and the geometry needs a color attribute.

The reference version

This is one possible result of step 3: 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>STEP 3 · ROCKS</span>
</div>
<p class="hint">MOVE THE MOUSE (OR ARROWS / WASD) TO FLY</p>
/* 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;
}
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
// 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;
}
// ---- 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);
});
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

// ---- 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;
  if (spawnTimer <= 0 && activeIn(rocks).length < ROCK_MAX) {
    spawnTimer = SPAWN_EVERY;
    launchRock(freeOne(rocks));
  }
  for (const rock of activeIn(rocks)) {
    drift(rock, dt);
    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);
  updateShip(dt);
  updateRocks(dt);

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

How it works

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

Fog

Fog blends everything toward one color with distance. Because the fog color is the background color, a rock 100 units away is almost invisible and becomes clearer as it approaches, instead of popping into view. Many stars are farther away than the fog's far end (110 units) and would vanish, so the stars switch fog off:

scene.fog = new THREE.Fog(SPACE_COLOR, FOG_NEAR, FOG_FAR);
starMaterial.fog = false; // stars are far beyond the fog, but should show

Low-poly rocks

A low-poly model is a 3D shape built from deliberately few flat triangles (polygons), so its facets show. It is cheap for the graphics card to draw and gives a clear, retro look. Each rock starts as an IcosahedronGeometry(1, 1), a ball of 80 triangles. Pushing every corner in or out a little makes it lumpy, and flatShading gives every triangle one flat shade.

An IcosahedronGeometry stores each triangle's corners separately, so one point on the surface appears several times. If each copy moved by a different amount, the triangles would separate and show cracks. The rule keeps a Map from position to push, so copies of the same point move together:

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;
}

The sixteen shapes and the one material are made once, at the start, and shared by every rock.

The object pool

An object pool is a fixed set of objects created once and reused: an object is taken from the pool when it is needed and returned when it is done, instead of being created and thrown away each time. In a game that launches a rock every 0.7 seconds for minutes on end, creating a mesh per rock means memory to allocate, objects to clean up and, in Three.js, geometries that are not released until someone calls dispose() (see Reading the Code You Got). A pool avoids all of it:

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);

Every frame, each active rock moves by its velocity times delta time, and a rock that has passed the camera is switched off: invisible and free for the next launch. The limit of 14 rocks in flight, well below the 30 in the pool, guarantees that freeOne always finds one, even if you later make rocks come much faster; what happens when a faster spawn rate meets a missing limit is a worked example on Debugging with AI.

Spawning

Spawning is bringing a new object into a running game: choosing where it starts, how it moves and when the next one comes. Here a timer counts down from 0.7 seconds; when it reaches zero and fewer than 14 rocks are in flight, a free rock from the pool gets a start point and a velocity:

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) };
}

The start is a random spot on a wide rectangle 95 units ahead; the goal is either the ship's current position (an aimed rock) or a random point near the area the ship can fly in. aimVelocity turns "from here to there" into a direction of length 1 times a speed, the vector arithmetic from length and distance. An AI may instead place rocks on a circle around the flight path with Math.cos and Math.sin of a random angle, as the stars in step 1 do; both work (see the unit circle).

The life of a rock, seen from the side. Rocks are launched 95 units ahead of the ship, deep in the fog, at different heights. Most fly toward the area around the ship; an aimed rock flies straight at the ship. The fog thins toward the camera, so the rocks appear out of the dark. Once a rock passes the camera, at z 12, it is switched off and returns to the pool, ready to be launched again. fog: thicker toward the far end z −95 launch z 12 to the pool ship z 0 camera aimed switched off, launched again later
Side view (not to scale): rocks start deep in the fog, fly toward the ship's area and go back to the pool behind the camera.

Passing random in, instead of calling Math.random() inside the function, is what makes the rule testable: a test can hand it fixed numbers and check that an aimed rock really ends up at the ship. That is how the reference version's rules were tested.