What you add

Until now the only way to survive was to dodge. In this step the jet shoots back. It is the biggest single step so far, which is why the page before this one was about saving versions and going back: if the answer breaks something, you want a working copy to return to.

  • Bursts. Holding Space fires a few quick shots, then pauses; a click on the game fires one burst. Shots only fire while you are playing, not during the countdown or on the GAME OVER screen.
  • Two guns and a reticle. Shots leave the wing tips, left and right in turn, and meet at a thin diamond 30 units ahead. When a rock is nearly in line, the diamond turns cyan and the shots bend slightly toward it.
  • Hits and explosions. A hit rock vanishes in a spray of square pixels. If a rock hits you, the jet bursts the same way.
  • A score. 10 points per rock, shown next to the time and on the GAME OVER screen.

The collision test from step 4 comes back here: the same check that catches a fast rock passing through the ship now catches a fast bullet passing through a rock.

Before you start

Save a known-good copy of step 4 before you paste anything new. On a free CodePen account there is no version history to go back to (as of October 2026), so make the copy yourself, as described on Saving Versions and Going Back:

  • Save the Pen (⌘S on a Mac, usually Ctrl+S elsewhere, or the Save button), then fork it and give the fork a name such as "Build 4 – working". Keep that copy untouched and work in the other one.
  • Or export the Pen as a .zip, or at least copy script.js into a text file on your computer.

Check that the game still runs as you left it: the countdown, the rocks, GAME OVER and the restart. A step that starts from a broken version is much harder to debug.

The prompt

Copy the prompt below into the same chat you used for step 4, or a new one (a new chat needs all three files, and the prompt already asks for them). Before you send it:

  1. Fill in the two brackets in the first point. The number of shots per burst and the pause between bursts are your first design choices in this project. The reference version uses 3 shots and 0.35 seconds; 1 shot and 0.2 seconds gives a steady single-shot gun, 5 shots and 0.6 seconds long rattling bursts. Pick numbers, not words such as "fast": numbers you can check.
  2. Replace the last line with your current index.html, style.css and script.js, each under its file name.
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 5: shooting.

- Holding Space fires bursts of [NUMBER OF SHOTS PER BURST, e.g. 3] shots,
  0.08 seconds apart, then waits [PAUSE BETWEEN BURSTS IN SECONDS, e.g.
  0.35] before the next burst. A click on the canvas fires one burst. Only
  while playing. Write the burst rhythm as a small pure function that gets
  the current burst state, whether the trigger is held, and dt, and returns
  the new state and whether to fire now. Do not use key repeat for this.
- Shots come alternately from the two gun barrels on the wing tips and fly
  toward a point 30 units ahead of the ship, with a slight fan (the shots
  of one burst land a little left, middle and right).
- Show that point as a reticle: a thin diamond ring. It lights up cyan when
  a rock is close to the line straight ahead; then the shots aim at that
  rock (a light aim assist, within a small angle).
- Bullets are glowing amber capsules (CapsuleGeometry, additive blending)
  with a fainter orange tail, from a pool of about 24 that is reused, and
  they disappear after 0.8 seconds. Check each bullet's whole path in the
  frame against the rocks, like the rocks against the ship.
- A hit rock disappears and bursts into square pixels: one shared THREE.Points
  pool of about 600 particles (sizeAttenuation false, additive blending).
  The pixels start on the rock's surface, fly outward and fade to black.
  When the ship is hit, it bursts the same way.
- 10 points per rock. Show SCORE next to TIME, and the score on the GAME
  OVER screen. Change the hint to "MOUSE TO FLY · SPACE OR CLICK TO FIRE".

Keep the pure logic (burst rhythm, shot target, which rock is in line,
explosion direction, fading) 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]

A few lines in this prompt do more than they seem to. "Do not use key repeat" and the request for a small pure function decide how firing works before the AI can choose for you (see How it works below). "Check each bullet's whole path" asks for the swept test from step 4 instead of a simpler test that misses. And "from a pool of about 24 that is reused" keeps the game from creating hundreds of objects a minute.

If you added the optional font line from step 1 at the top of style.css (the @import of the "Press Start 2P" font from Google Fonts), check that it is still there in the answer. It is not part of the reference panels below: this site's game frame loads nothing from the network and supplies the pixel font itself, and without the line a Pen falls back to Courier New.

What you should see

  • A thin diamond far ahead of the jet that moves with it.
  • Holding Space: short bursts of shots, then a pause, then the next burst. A click fires one burst.
  • Shots start at the wing tips, left and right in turn, and come together at the diamond.
  • When a rock is right in front of you, the diamond turns cyan and the shots bend slightly toward it.
  • A hit rock vanishes in a spray of small square pixels that fade out.
  • SCORE goes up by 10 per rock; GAME OVER shows the score.
  • If the ship is hit, it bursts into cream pixels.

If it goes wrong

Go through the list below first. For anything else, the method on Debugging with AI applies: the exact error from the console, one problem per prompt, and back to your saved copy when a fix makes things worse.

  • Holding Space fires one shot, waits, then fires very fast: the code uses the keyboard's key repeat. Ask the AI to drive firing from the set of held keys and the burst timer, not from keydown events.
  • Bullets fly through rocks: at 90 units per second a bullet moves 1.5 units per frame, more than a small rock is wide. Ask for the bullet's whole path in the frame to be checked, not only its end point.
  • The explosions are invisible: additive pixels need a bright color. Check that the colors fade from bright to black, and that needsUpdate is set on the position and color attributes every frame.
  • Pixels appear only at one spot, or far away: the corners of the rock must be turned into world positions with the rock's matrixWorld.
  • The game stutters after a while: something is created every frame. A new Vector3 in a loop is fine; a new Mesh or geometry is not. Ask the AI which objects are created inside the animation loop.
  • Shots come out of the middle of the ship: ask the AI where the gun positions are defined and to use the wing-tip positions.

The reference version

This is one possible result of step 5: the tested reference version, with 3 shots per burst and a 0.35-second pause. Play runs it right here; the code under it is the three panels to paste into a Pen (the HTML goes inside <body>, then the CSS and the JavaScript). The frame on this page is sandboxed, so a few things behave differently from CodePen: it loads nothing from the network (an external font line would be blocked; the site supplies the pixel font itself), it has no storage, so a high score would not be remembered, and it hides fullscreen. None of that matters yet in step 5; it will from step 6 on.

  • Click in the game once before you use the keyboard; until then your keys go to the page, not the game. (On this page, Play moves the focus into the game for you.)
  • The game needs WebGL 2. According to caniuse, about 96.44% of browsers in use support it, but the three.js manual warns that some devices or browsers still do not; without it the frame stays blank.
  • In CodePen 2.0 the bare import * as THREE from "three" makes CodePen add the latest Three.js version itself, which may be newer than the r186 (three@0.186.1) this version was built against, as of October 2026.
<canvas id="game"></canvas>
<div class="hud">
  <span class="title">RETRO JET</span>
  <span>SCORE <span id="score">0</span> · TIME <span id="time">0:00</span></span>
</div>
<p class="hint">MOUSE TO FLY · SPACE OR CLICK TO FIRE</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

// Guns on the wing tips (x, y, z from the ship's middle).
const GUNS = [
  { x: -1.7, y: -0.02, z: -0.15 },
  { x: 1.7, y: -0.02, z: -0.15 },
];
const AIM_DISTANCE = 30; // the shots meet at the reticle, this far ahead
const BURST_SIZE = 3; // shots per burst
const BURST_GAP = 0.08; // seconds between the shots of a burst
const BURST_PAUSE = 0.35; // seconds before the next burst
const FAN = 0.6; // sideways spread of a burst at the reticle
const BULLET_POOL = 24;
const BULLET_SPEED = 90; // units per second
const BULLET_LIFE = 0.8; // seconds
const BULLET_RADIUS = 0.25; // a bit generous, so near misses count
const ASSIST_ANGLE = 0.06; // radians: rocks this close to the line get aimed at
const PARTICLE_POOL = 600; // pixels shared by all explosions
const PARTICLE_LIFE = 0.9; // seconds
const POINTS_PER_ROCK = 10;
// 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")}`;
}

// The burst rhythm, one frame at a time. burst = { left, wait }: shots
// left in this burst and seconds until the next may fire. Returns the
// new burst and whether to fire now.
function burstStep(burst, trigger, dt) {
  const wait = Math.max(0, burst.wait - dt);
  if (wait > 0 || (burst.left === 0 && !trigger)) {
    return { left: burst.left, wait, fire: false };
  }
  const left = (burst.left === 0 ? BURST_SIZE : burst.left) - 1;
  return { left, wait: left > 0 ? BURST_GAP : BURST_PAUSE, fire: true };
}

// Where shot number 0, 1 or 2 of a burst goes: the reticle straight
// ahead, a little to the left, middle or right (a slight fan).
function shotTarget(shipAt, shotIndex) {
  return {
    x: shipAt.x + (shotIndex - 1) * FAN,
    y: shipAt.y,
    z: shipAt.z - AIM_DISTANCE,
  };
}

// Light aim assist: which rock (index) is closest to the line straight
// ahead, if it is within ASSIST_ANGLE of it? -1 when none is.
function assistTarget(from, rockPositions) {
  let best = -1;
  let bestAngle = ASSIST_ANGLE;
  rockPositions.forEach((rock, i) => {
    const ahead = from.z - rock.z;
    if (ahead <= 2) return; // behind or right next to us
    const angle = Math.atan2(
      Math.hypot(rock.x - from.x, rock.y - from.y),
      ahead,
    );
    if (angle < bestAngle) {
      best = i;
      bestAngle = angle;
    }
  });
  return best;
}

// Aim ahead of a moving rock: where it will be when the bullet arrives.
function leadPoint(rockAt, rockVelocity, from, bulletSpeed) {
  const time =
    Math.hypot(rockAt.x - from.x, rockAt.y - from.y, rockAt.z - from.z) /
    bulletSpeed;
  return {
    x: rockAt.x + rockVelocity.x * time,
    y: rockAt.y + rockVelocity.y * time,
    z: rockAt.z + rockVelocity.z * time,
  };
}

// An explosion pixel flies away from the middle of what exploded.
function outward(point, center, speed) {
  const dx = point.x - center.x;
  const dy = point.y - center.y;
  const dz = point.z - center.z;
  const length = Math.hypot(dx, dy, dz) || 1;
  return {
    x: (dx / length) * speed,
    y: (dy / length) * speed,
    z: (dz / length) * speed,
  };
}

// How bright a pixel still is: 1 when new, 0 when its life is over.
function fade(life, maxLife) {
  return clamp(life / maxLife, 0, 1);
}
// ---- 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

// ---- Bullets: glowing capsules with a tail, from a pool ----
const glowLook = (color, opacity = 1) =>
  new THREE.MeshBasicMaterial({
    color,
    transparent: true,
    opacity,
    blending: THREE.AdditiveBlending, // adds light: it glows
    depthWrite: false,
    fog: false,
  });
const bulletShape = new THREE.CapsuleGeometry(0.07, 0.5, 4, 8);
bulletShape.rotateX(Math.PI / 2); // lengthwise along z
const tailShape = new THREE.CapsuleGeometry(0.04, 1.6, 4, 6);
tailShape.rotateX(Math.PI / 2);
tailShape.translate(0, 0, 1.1); // behind the bullet
const bullets = makePool(BULLET_POOL, () => {
  const bullet = new THREE.Mesh(bulletShape, glowLook(PALETTE.amber));
  bullet.add(new THREE.Mesh(tailShape, glowLook(PALETTE.flame, 0.5)));
  bullet.name = "bullet";
  return bullet;
});
let burst = { left: 0, wait: 0 };
let clicked = false; // a click asks for one burst
let shotCount = 0; // to alternate between the two guns

// ---- The reticle: a diamond where the shots meet ----
const reticle = new THREE.Mesh(
  new THREE.RingGeometry(0.8, 0.95, 4),
  new THREE.MeshBasicMaterial({
    color: PALETTE.ice,
    transparent: true,
    depthTest: false,
    fog: false,
  }),
);
reticle.name = "reticle";
reticle.renderOrder = 1; // drawn last, on top of everything
scene.add(reticle);
let assisted = null; // the rock the aim assist picked, or null

function fireShot() {
  const shotIndex = BURST_SIZE - 1 - burst.left; // 0, 1, 2
  const gun = GUNS[shotCount++ % GUNS.length];
  const from = {
    x: ship.position.x + gun.x,
    y: ship.position.y + gun.y,
    z: ship.position.z + gun.z,
  };
  const aimAt = assisted
    ? leadPoint(assisted.mesh.position, assisted.velocity, from, BULLET_SPEED)
    : shotTarget(ship.position, shotIndex);
  const bullet = freeOne(bullets);
  if (!bullet) return;
  switchOn(bullet, from, aimVelocity(from, aimAt, BULLET_SPEED));
  bullet.life = BULLET_LIFE;
  bullet.mesh.lookAt(aimAt.x, aimAt.y, aimAt.z); // point the capsule where it flies
}

// ---- Explosions: pixels from one shared pool (one Points object) ----
// A pixel's color fades to black; with additive blending black adds
// nothing, so a finished pixel is simply invisible.
const particlePositions = new Float32Array(PARTICLE_POOL * 3);
const particleColors = new Float32Array(PARTICLE_POOL * 3);
const particles = Array.from({ length: PARTICLE_POOL }, () => ({ life: 0 }));
const particleGeometry = new THREE.BufferGeometry();
particleGeometry.setAttribute("position", attribute(particlePositions));
particleGeometry.setAttribute("color", attribute(particleColors));
const particleLook = new THREE.PointsMaterial({ size: 4, vertexColors: true });
particleLook.sizeAttenuation = false; // square pixels, whatever the distance
particleLook.blending = THREE.AdditiveBlending;
particleLook.depthWrite = false;
const particleCloud = new THREE.Points(particleGeometry, particleLook);
particleCloud.frustumCulled = false; // the pixels move; always draw them
scene.add(particleCloud);
let nextParticle = 0; // the oldest pixel, reused first
const corner = new THREE.Vector3(); // reused, so we make no garbage

// Pixels start on the surface of the mesh (at its corners) and fly out.
function explode(mesh, count, rgb, drift = { x: 0, y: 0, z: 0 }) {
  mesh.updateMatrixWorld();
  const surface = mesh.geometry.attributes.position;
  const center = mesh.getWorldPosition(new THREE.Vector3());
  for (let i = 0; i < count; i++) {
    corner.fromBufferAttribute(
      surface,
      Math.floor(Math.random() * surface.count),
    );
    corner.applyMatrix4(mesh.matrixWorld); // from the mesh's own space to the world
    const particle = particles[nextParticle];
    const away = outward(corner, center, 3 + Math.random() * 8);
    particle.velocity = {
      x: away.x + drift.x * 0.3,
      y: away.y + drift.y * 0.3,
      z: away.z + drift.z * 0.3,
    };
    particle.life = PARTICLE_LIFE * (0.6 + Math.random() * 0.4);
    particle.maxLife = particle.life; // to know how far it has faded
    particle.rgb = Math.random() < 0.5 ? rgb : hexToRgb(PALETTE.amber);
    particlePositions.set([corner.x, corner.y, corner.z], nextParticle * 3);
    nextParticle = (nextParticle + 1) % PARTICLE_POOL;
  }
}

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

// ---- 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 scoreText = $("#score");
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";
  explode(ship.children[0], 160, hexToRgb(PALETTE.hull)); // the hull bursts
  ship.visible = false;
  const text = `SCORE ${score} · TIME ${formatTime(survived)}`;
  showOverlay("GAME OVER", text, "PLAY AGAIN");
}

// Back to the start: clear the rocks, ship at its start, count down.
function restart() {
  if (state !== "over") return;
  rocks.forEach(switchOff);
  bullets.forEach(switchOff);
  burst = { left: 0, wait: 0 };
  score = 0;
  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);
canvas.addEventListener("pointerdown", () => (clicked = true)); // click = fire

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

function updateGuns(dt) {
  // Which rock is in line? The reticle lights up cyan when there is one.
  const ahead = activeIn(rocks);
  const pick = assistTarget(
    ship.position,
    ahead.map((r) => r.mesh.position),
  );
  assisted = pick >= 0 ? ahead[pick] : null;
  reticle.position.set(
    ship.position.x,
    ship.position.y,
    ship.position.z - AIM_DISTANCE,
  );
  reticle.material.color.setHex(assisted ? PALETTE.cyan : PALETTE.ice);
  reticle.material.opacity = assisted ? 1 : 0.45;

  // Space held (or a click) fires bursts of three.
  const trigger = state === "playing" && (held.has("Space") || clicked);
  burst = burstStep(burst, trigger, dt);
  if (burst.fire) {
    fireShot();
    clicked = false;
  }

  for (const bullet of activeIn(bullets)) {
    const from = bullet.mesh.position.clone();
    drift(bullet, dt);
    bullet.life -= dt;
    if (bullet.life <= 0) switchOff(bullet);
    // Check the bullet's whole path this frame, like the rocks.
    const hit = activeIn(rocks).find(
      (rock) =>
        segmentDistance(from, bullet.mesh.position, rock.mesh.position) <
        rock.radius + BULLET_RADIUS,
    );
    if (hit && bullet.active) {
      switchOff(bullet);
      switchOff(hit);
      explode(hit.mesh, 40, hexToRgb(SLATE[2]), hit.velocity);
      score += POINTS_PER_ROCK;
    }
  }
}

function updateParticles(dt) {
  particles.forEach((particle, i) => {
    if (particle.life <= 0) return;
    particle.life -= dt;
    for (const [k, axis] of ["x", "y", "z"].entries()) {
      particlePositions[i * 3 + k] += particle.velocity[axis] * dt;
    }
    const glow = fade(particle.life, particle.maxLife);
    particleColors.set(
      particle.rgb.map((c) => c * glow),
      i * 3,
    );
  });
  // Tell Three.js the numbers changed.
  particleGeometry.attributes.position.needsUpdate = true;
  particleGeometry.attributes.color.needsUpdate = true;
}

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
  updateGuns(dt);
  updateParticles(dt);
  scoreText.textContent = score;
  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. Yours will be organized differently; look for the same ideas in it, and ask the AI to point them out if you cannot find them (see Reading the Code You Got).

Bursts and a cooldown

A cooldown is a minimum waiting time after an action before the same action can happen again. In this game there are two: 0.08 seconds between the shots of one burst, and 0.35 seconds after the last shot before the next burst may start. Without a cooldown, a held Space bar would fire on every frame: 60 shots a second on one screen, 144 on another.

The obvious shortcut is the keyboard itself. Key repeat is the operating system sending the same key again and again while you hold it down: after a short delay, the browser fires extra keydown events, with the event's repeat property set to true. The delay and the rate come from the computer's keyboard settings, not from your game, which explains the symptom "one shot, a wait, then very fast". So the prompt asks for something else: the key handler only records which keys are held (the set from step 2), and a small pure function decides, frame by frame, whether to fire:

// The burst rhythm, one frame at a time. burst = { left, wait }: shots
// left in this burst and seconds until the next may fire. Returns the
// new burst and whether to fire now.
function burstStep(burst, trigger, dt) {
  const wait = Math.max(0, burst.wait - dt);
  if (wait > 0 || (burst.left === 0 && !trigger)) {
    return { left: burst.left, wait, fire: false };
  }
  const left = (burst.left === 0 ? BURST_SIZE : burst.left) - 1;
  return { left, wait: left > 0 ? BURST_GAP : BURST_PAUSE, fire: true };
}

burst.wait counts down by delta time, the seconds since the last frame, so the rhythm is the same at 60 and at 144 frames per second. When a shot fires, the wait becomes the short gap if shots are left in the burst, and the long pause when it was the last one. A burst that has started is finished even when you let go of Space halfway: the function only looks at the trigger when no shots are left. Called from the animation loop, it looks like this:

// Space held (or a click) fires bursts of three.
const trigger = state === "playing" && (held.has("Space") || clicked);
burst = burstStep(burst, trigger, dt);
if (burst.fire) {
  fireShot();
  clicked = false;
}
The burst rhythm while Space is held, with the reference values. Three shots 0.08 seconds apart, then a pause of 0.35 seconds, then the next burst of three. The bursts start at 0, 0.51 and 1.02 seconds. burst 1 burst 2 burst 3 0 s 0.5 s 1.0 s gaps 0.08 s pause 0.35 s time, Space held
Three quick shots, then a pause. Each wait ends on the first frame after it runs out, so at 60 frames per second the real times are a few milliseconds later than these.

Bullets from a pool

A projectile is an object that is launched once and then moves on its own, a little further every frame (its velocity times delta time), until it hits something or its time is up. Bullets here live 0.8 seconds. Like the rocks in step 3, they come from a pool: 24 bullet meshes are made once, switched on when fired and switched off when they hit or expire, so no new objects are created while you play.

The hit test is the swept test from step 4. At 90 units per second a bullet moves 1.5 units per frame at 60 frames per second, and up to 4.5 units in one long frame (the game caps a frame at 0.05 seconds). The smallest rocks are 1.4 units across. A test that only looks at where the bullet ends up would let many shots pass straight through, so the code measures the distance from the rock to the line the bullet traveled in this frame:

for (const bullet of activeIn(bullets)) {
  const from = bullet.mesh.position.clone();
  drift(bullet, dt);
  bullet.life -= dt;
  if (bullet.life <= 0) switchOff(bullet);
  // Check the bullet's whole path this frame, like the rocks.
  const hit = activeIn(rocks).find(
    (rock) =>
      segmentDistance(from, bullet.mesh.position, rock.mesh.position) <
      rock.radius + BULLET_RADIUS,
  );
  if (hit && bullet.active) {
    switchOff(bullet);
    switchOff(hit);
    explode(hit.mesh, 40, hexToRgb(SLATE[2]), hit.velocity);
    score += POINTS_PER_ROCK;
  }
}

The reticle and a light aim assist

The reticle is a ring with four segments, which makes a diamond, placed 30 units in front of the ship and drawn on top of everything. Every frame, a pure function looks for the rock with the smallest angle to the line straight ahead; if one is within 0.06 radians (about 3.4 degrees), the reticle turns cyan and the next shots aim at it. Aiming at where the rock is now would miss a moving rock, so the shots aim at where it will be when the bullet arrives: the distance divided by the bullet speed gives the flight time, and the rock's velocity times that time gives the lead. The angles and distances come from geometry and trigonometry and vectors.

Pixel explosions

A particle effect draws an explosion, smoke or sparks as many small particles, each with its own position, velocity and lifetime, updated every frame. Here all explosions share one THREE.Points object with 600 pixels. A new explosion reuses the oldest pixels, starts them on the corners of the rock's mesh (turned into world positions with matrixWorld) and sends them outward:

corner.applyMatrix4(mesh.matrixWorld); // from the mesh's own space to the world
const particle = particles[nextParticle];
const away = outward(corner, center, 3 + Math.random() * 8);

With additive blending, a pixel adds its color to what is behind it, so black adds nothing. That is why a pixel only has to fade its color to black: a finished pixel is invisible without being removed. Because the numbers change every frame, the code also has to tell Three.js to send them to the graphics card again:

// Tell Three.js the numbers changed.
particleGeometry.attributes.position.needsUpdate = true;
particleGeometry.attributes.color.needsUpdate = true;

Forget those two lines and the explosions stay frozen or never appear, which is the third item under If it goes wrong.