What you add

Whatever you think the game is missing. After step 7 the game plays, sounds and reacts; step 8 is about making it yours. You use the same template method as in steps 6 and 7 (from Writing Your Own Prompt), but now in small rounds: one idea, one prompt, try it, save, next idea. Some directions:

  • A start: a title screen with the controls, before the countdown.
  • More to decide: lives, a stronger shield, power-ups, something you must not shoot.
  • More to see: a background, dust, a shader effect.
  • More people who can play: touch controls with pointer events (drag to fly, tap to fire), larger text, fewer flashes.

The reference version took mostly the first three directions: a title screen, a dim galaxy and fine dust, a shield that glows at its edges, a friendly UFO every 25 seconds that costs points if you hit it, and an orange orb that gives a spread shot. Your step 8 can be completely different.

Before you start

Save a known-good version of step 7: fork the Pen ("Retro Jet · step 7 works") or export it as a .zip file. Then do the same after every idea that works ("step 8 · title screen", "step 8 · UFO"). Step 8 is the longest round of the build, and a free CodePen account has no history to roll back to; these copies are your checkpoints (see Saving Versions and Going Back).

Also write down, before you prompt, what the idea is for. "A UFO" is a picture; "a target I must not shoot, so I stop holding Space all the time" is a game idea, and it tells you and the AI what matters (see hint 2 below).

Your prompt

You write the prompt; the template below gives it a shape. Fill in every [BRACKET], and use the template again for each new idea. The reference version is one possible answer to a few prompts like this; your game will differ, and that is the point.

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.

I want to add: [THE IDEA IN ONE SENTENCE]

How it works for the player:
- When it happens: [TRIGGER, e.g. every 25 seconds, when a rock breaks
  with a 10% chance, at the start]
- What the player sees and hears: [LOOK, COLOR, SOUND]
- What the player can do with it: [CONTROLS, if any]
- What it changes in the game: [EFFECT, with numbers and a duration]
- How it ends: [WHEN IT GOES AWAY]

Rules for the code:
- Reuse what exists ([e.g. the particle pool, the glow sprite, the popup
  text, the pool helpers]); create nothing new while the game runs.
- Put the new pure logic ([NAME IT, e.g. the UFO's path, the chance of a
  drop]) in small functions without Three.js or the DOM in the rules
  section, with any random() passed in.
- Restart must reset it, and pause must freeze it.
- Comment for a beginner.

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

The line about pure logic "with any random() passed in" is there for testing: a function that receives its random numbers can be run with fixed numbers and always gives the same result. That is how the reference game's rules were tested, and how you would test yours when the game moves out of CodePen (see From Prototype to Real Software).

What to mention

  • The trigger, the look, the effect and the end of the idea, each with numbers.
  • How it interacts with what exists: does the aim assist aim at it, can it hit the ship, does it count for the combo?
  • What restart and pause do with it.
  • That it must reuse pools and shared geometry, and which pure functions you want to test.
  • For a start screen: what it shows, how to start (key, click), and that the countdown comes after it.
  • For touch screens: dragging a finger steers (pointer events), tapping fires.
  • One idea per prompt. If the answer breaks something, go back to your last good version instead of piling fixes on top.

Three hints

  1. Play it before you ask for more. Ten minutes of playing tells you more than a long prompt: is it too easy at level 1, is the orb too hard to catch, is the UFO annoying? Change one tuning constant at a time (the numbers at the top of the rules section) before you ask the AI for new code.
  2. A good idea changes a decision, not only the picture. A UFO that costs points if you shoot it makes you stop and think before holding Space. A power-up that flies toward where you were makes you choose between catching it and dodging. Ask yourself what the player has to decide.
  3. Shaders are small programs, so check them in the browser. A glow at the edges of a bubble (a "fresnel" effect) takes a ShaderMaterial of a few lines. If the screen goes black or the console shows a shader compile error, paste that exact error back to the AI. These errors only show up in a real browser with WebGL.

What you should see

In the reference version:

  • A title screen RETRO JET with START, the controls and "STEP 8 · ONE POSSIBLE FINISHED VERSION". Enter, Space or a click on START: the title fades out and the countdown begins.
  • A faint blue-violet band of a galaxy across the sky, and fine dust streaming past faster than the stars.
  • The shield is clear in the middle and glows at its edges, pulsing gently.
  • Every 25 seconds a friendly UFO crosses the view. Shooting it costs 50 points ("-50"), resets the combo, and it flees upward.
  • Sometimes a broken rock drops an orange orb that flies toward you. Catch it: "SPREAD!", the SPREAD square lights up, and for 8 seconds every shot is three.
  • COMBO x3! and LEVEL 2 banners in the middle of the screen, and NEW HIGHSCORE! at the end when you beat it.
  • With a finger on a touch screen: drag to fly, tap to fire.

If it goes wrong

The general method is on Debugging with AI. Problems typical for this step:

  • The new thing stays after a restart. Restart must switch it off and reset its timers. Ask the AI to add it to the restart function.
  • It keeps moving while paused. Its update function runs outside the "not paused" part of the loop.
  • The shield is a solid white ball, or the screen is black. The shader did not compile. Open the console, copy the error, and ask the AI to fix only that.
  • The game got slow. Something is created every frame: a mesh, a geometry, a material or a texture. Ask the AI to list everything that is created inside the loop.
  • You lost track of what changed. Compare with your last known-good version (fork or export), and ask the AI to explain the difference before you accept it.

The reference version

This is one possible result of step 8: the finished version tested for this topic, built against Three.js r186 (three@0.186.1, as of October 2026; with the bare "three" import, CodePen adds the latest version itself). Play runs it right here. Below it are the three panels to paste into a Pen: the HTML goes inside <body>, then the CSS and the JavaScript. Click in the game once before you use the keyboard. The game needs WebGL 2; without it the frame stays blank.

This frame is sandboxed, so some things behave differently than in CodePen. It has no network, so external fonts are not loaded (the site supplies the pixel font itself); it has no storage, so the high score lasts only as long as the frame runs; and fullscreen is not allowed, so the button is hidden. Sound waits for your first key press or click in the frame.

<canvas id="game"></canvas>
<div class="hud">
  <span class="title">RETRO JET</span>
  <span id="combo"></span>
</div>
<p id="countdown" class="countdown" hidden>3</p>
<p id="banner" class="banner" hidden></p>
<div id="overlay" class="overlay intro">
  <h2 id="overlay-title">RETRO JET</h2>
  <p id="overlay-text">STEP 8 · ONE POSSIBLE FINISHED VERSION</p>
  <button id="overlay-button" type="button">START</button>
  <p id="overlay-small" class="small">OR PRESS ENTER</p>
  <p class="help">
    MOUSE OR FINGER: FLY · SPACE OR CLICK: FIRE · P: PAUSE · M: SOUND<br />
    DON'T SHOOT THE UFOS · CATCH THE ORANGE ORB FOR A SPREAD SHOT
  </p>
</div>
<div class="console">
  <button id="restart" type="button">RESTART</button>
  <span>LEVEL <b id="level">1</b></span>
  <span>COUNTER <b id="kills">0</b></span>
  <span>SCORE <b id="score">0</b></span>
  <span>HIGHSCORE <b id="highscore">0</b></span>
  <span>SHIELD <i id="shield" class="pip on"></i></span>
  <span>SPREAD <i id="spread" class="pip spread"></i></span>
  <button id="sound" type="button">SOUND: ON</button>
  <button id="fullscreen" type="button" aria-label="Fullscreen">[ ]</button>
</div>
<div id="popups" class="popups" aria-hidden="true"></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);
}

.hud #combo {
  color: var(--amber);
}

/* The console: one row of game info along the bottom. */
.console {
  position: fixed;
  inset: auto 0 0 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 8px 22px;
  padding: 10px 14px;
  font: 9px/1.6 var(--font);
  letter-spacing: 0.08em;
  color: var(--cyan-deep);
  background: rgb(10 18 32 / 0.85);
  border-top: 2px solid var(--cyan-deep);
}

.console b {
  font-weight: 400;
  color: var(--ice);
}

.console button {
  padding: 6px 10px;
  font: inherit;
  color: var(--amber);
  background: transparent;
  border: 2px solid var(--copper);
  cursor: pointer;
}

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

/* The shield pip: an empty square, filled while the shield is up. */
.pip {
  display: inline-block;
  width: 10px;
  height: 10px;
  vertical-align: -1px;
  border: 2px solid var(--cyan);
}

.pip.on {
  background: var(--cyan);
  box-shadow: 0 0 8px var(--cyan);
}

/* "+10" score popups, placed by script.js over the broken rock. */
.popups span {
  position: fixed;
  left: 0;
  top: 0;
  font: 12px/1 var(--font);
  color: var(--amber);
  text-shadow: 0 0 8px var(--flame);
  pointer-events: none;
}

/* Step 8: the intro screen has a big glowing title. */
.overlay.intro h2 {
  font-size: 40px;
  color: var(--cyan);
  text-shadow:
    0 0 18px var(--cyan-deep),
    4px 4px 0 var(--teal);
}

/* The help text shows only on the intro screen. */
.overlay .help {
  margin-top: 12px;
  font-size: 8px;
  line-height: 2;
  color: var(--cyan-deep);
}

.overlay:not(.intro) .help {
  display: none;
}

/* COMBO x3! in the middle of the screen, for a moment. */
.banner {
  position: fixed;
  inset: 30% 0 auto 0;
  margin: 0;
  text-align: center;
  font: 20px/1 var(--font);
  color: var(--amber);
  text-shadow: 0 0 14px var(--flame);
  pointer-events: none;
}

.banner[hidden] {
  display: none;
}

.pip.spread {
  border-color: var(--amber);
}

.pip.spread.on {
  background: var(--amber);
  box-shadow: 0 0 8px var(--flame);
}
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 SPAWN_Z = -95; // rocks appear this far ahead
const DESPAWN_Z = 12; // and are recycled once they pass the camera
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;

const KILLS_PER_LEVEL = 10; // a new level every 10 rocks
const MAX_LEVEL = 10;
// Level 1 is EASY, level 10 is HARD; levels in between are mixed.
const EASY = { maxRocks: 8, spawnEvery: 0.9, speed: 20, aimChance: 0.15 };
const HARD = { maxRocks: 24, spawnEvery: 0.25, speed: 44, aimChance: 0.6 };
const COMBO_WINDOW = 1.5; // seconds: hit again within this for a combo
const MAX_COMBO = 5;
const SAFE_TIME = 1.5; // seconds the ship blinks and cannot be hit
const HIGHSCORE_KEY = "retro-jet-highscore"; // the name it is saved under

const WARN_FAR = -60; // a far rock glows red...
const WARN_NEAR = -30; // ...and the glow is gone when it gets this close
const TRAIL_RATE = 70; // flame pixels per second from the engine
const FLASH_POWER = 80; // brightness of the light flash of an explosion
const FLASH_DECAY = 9; // how quickly a flash fades
const POPUP_LIFE = 0.8; // seconds a "+10" floats up

const DUST_COUNT = 400; // fine dust close by: it streams faster than stars
const DUST_SPEED = 45;
const INTRO_FADE = 0.8; // seconds for the title screen to fade away
const BANNER_TIME = 0.9; // seconds a "COMBO x3!" banner stays
const UFO_EVERY = 25; // seconds between friendly UFOs
const UFO_Z = -38; // UFOs cross the view this far ahead
const UFO_SPEED = 7;
const UFO_RADIUS = 1.3;
const UFO_PENALTY = 50; // points lost for shooting a friendly UFO
const ORB_CHANCE = 0.1; // chance that a broken rock drops a power-up orb
const ORB_SPEED = 16;
const SPREAD_TIME = 8; // seconds of spread shot after catching an orb
const SPREAD_WIDTH = 4; // sideways distance of the extra shots at the reticle
// 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)));
}

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

function levelFor(kills) {
  return Math.min(MAX_LEVEL, 1 + Math.floor(kills / KILLS_PER_LEVEL));
}

// The difficulty curve: a level -> how many rocks, how often, how fast,
// and how many aim at the ship. A straight line from EASY to HARD.
function difficulty(level) {
  const t = (clamp(level, 1, MAX_LEVEL) - 1) / (MAX_LEVEL - 1); // 0 to 1
  const mix = (easy, hard) => easy + (hard - easy) * t;
  return {
    maxRocks: Math.round(mix(EASY.maxRocks, HARD.maxRocks)),
    spawnEvery: mix(EASY.spawnEvery, HARD.spawnEvery),
    speed: mix(EASY.speed, HARD.speed),
    aimChance: mix(EASY.aimChance, HARD.aimChance),
  };
}

// Quick kills build a combo (x2, x3 ... up to x5); a slow one resets it.
function nextCombo(combo, sinceLastKill) {
  return sinceLastKill <= COMBO_WINDOW ? Math.min(MAX_COMBO, combo + 1) : 1;
}

// The highscore saved in the browser, or 0. Reading storage can throw
// (in a sandboxed frame it does), and the saved text can be rubbish.
function loadHighscore(getStorage) {
  try {
    const saved = Number(getStorage().getItem(HIGHSCORE_KEY));
    return Number.isFinite(saved) && saved > 0 ? Math.floor(saved) : 0;
  } catch {
    return 0; // no storage: the highscore lasts until the page closes
  }
}

// Saves the highscore; returns false when the browser would not let us.
function saveHighscore(getStorage, score) {
  try {
    getStorage().setItem(HIGHSCORE_KEY, String(score));
    return true;
  } catch {
    return false;
  }
}

// How strongly a rock's red warning glows: 1 far away, 0 up close.
function warningGlow(z) {
  return clamp((WARN_NEAR - z) / (WARN_NEAR - WARN_FAR), 0, 1);
}

// A soft round spot as RGBA pixels: white, fading out from the middle.
function glowPixels(size) {
  const pixels = new Uint8Array(size * size * 4);
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const d = Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) / (size / 2);
      const alpha = Math.max(0, 1 - d) ** 2;
      pixels.set([255, 255, 255, Math.round(alpha * 255)], (y * size + x) * 4);
    }
  }
  return pixels;
}

// Fades smoothly: the same share is lost every second.
function decay(value, rate, dt) {
  return value * Math.exp(-rate * dt);
}

// Three.js screen coordinates (-1 to 1, y up) -> CSS pixels (y down).
function toScreen(ndcX, ndcY, width, height) {
  return { x: ((ndcX + 1) / 2) * width, y: ((1 - ndcY) / 2) * height };
}

// P switches between "playing" and "paused"; other states stay.
function togglePause(state) {
  if (state === "playing") return "paused";
  if (state === "paused") return "playing";
  return state;
}

// White noise for explosions: random numbers from -1 to 1.
function noiseSamples(count, random) {
  return Float32Array.from({ length: count }, () => random() * 2 - 1);
}

// The engine hum: deeper and quieter when slow, higher when fast.
function engineHum(speed) {
  return {
    frequency: 55 + speed * 4,
    volume: 0.015 + Math.min(speed, 20) * 0.001,
  };
}

// A dust grain: like a star, but close by and in a narrower tube.
function randomDust(random) {
  const angle = random() * Math.PI * 2;
  const radius = 1.5 + random() * 12;
  return [
    Math.cos(angle) * radius,
    Math.sin(angle) * radius,
    -60 + random() * 72,
  ];
}

// Move toward the camera and wrap from near back to far.
function wrapDepth(z, dt, speed, near, far) {
  const next = z + speed * dt;
  return next > near ? next - (near - far) : next;
}

// The galaxy: a faint band across the sky. Brightness (0 to 0.3) of a
// point on a big sphere, highest near a tilted plane through the middle.
function galaxyBrightness(x, y, z) {
  const length = Math.hypot(x, y, z) || 1;
  const height = (y * Math.cos(0.5) - x * Math.sin(0.5)) / length;
  const swirl = 0.7 + 0.3 * Math.sin(Math.atan2(z, x) * 3);
  return 0.3 * Math.exp(-height * height * 40) * swirl;
}

// A UFO crosses the view from one side to the other, at a height the
// ship can reach (so you can hit it by accident).
function ufoPath(random, bounds) {
  const fromLeft = random() < 0.5;
  const side = bounds.halfWidth * 4;
  return {
    start: {
      x: fromLeft ? -side : side,
      y: bounds.minY + random() * (bounds.maxY - bounds.minY), // in reach
      z: UFO_Z,
    },
    velocity: { x: fromLeft ? UFO_SPEED : -UFO_SPEED, y: 0, z: 0 },
  };
}

// With the spread shot, each shot becomes three: left, middle, right.
function spreadTargets(target, spreading) {
  if (!spreading) return [target];
  return [-SPREAD_WIDTH, 0, SPREAD_WIDTH].map((dx) => ({
    ...target,
    x: target.x + dx,
  }));
}
// ---- 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);

// ---- A dim galaxy painted on a huge sphere around everything ----
const galaxyShape = new THREE.SphereGeometry(300, 48, 24);
const sky = galaxyShape.attributes.position;
const skyColors = new Float32Array(sky.count * 3);
const [tealR, tealG, tealB] = hexToRgb(PALETTE.cyanDeep);
for (let i = 0; i < sky.count; i++) {
  const glow = galaxyBrightness(sky.getX(i), sky.getY(i), sky.getZ(i));
  skyColors.set(
    [glow * (tealR + 0.4), glow * tealG, glow * (tealB + 0.3)],
    i * 3,
  );
}
galaxyShape.setAttribute("color", attribute(skyColors));
const galaxy = new THREE.Mesh(
  galaxyShape,
  new THREE.MeshBasicMaterial({
    vertexColors: true,
    side: THREE.BackSide, // we look at the inside of the sphere
    fog: false,
    depthWrite: false,
  }),
);
scene.add(galaxy);

// ---- Dust: small grains close by, streaming past fast ----
const dustPositions = new Float32Array(DUST_COUNT * 3);
for (let i = 0; i < DUST_COUNT; i++)
  dustPositions.set(randomDust(Math.random), i * 3);
const dustGeometry = new THREE.BufferGeometry();
dustGeometry.setAttribute("position", attribute(dustPositions));
const dust = new THREE.Points(
  dustGeometry,
  new THREE.PointsMaterial({
    size: 0.06,
    color: PALETTE.ice,
    transparent: true,
    opacity: 0.45,
  }),
);
dust.frustumCulled = false;
scene.add(dust);

// A soft glowing spot, used for the engine glow and the rock warnings.
// A Sprite always faces the camera; this texture is made from our own
// pixels, so it needs no image file.
const glowMap = new THREE.DataTexture(glowPixels(64), 64, 64);
glowMap.needsUpdate = true;
const glowSprite = (color, size) => {
  const sprite = new THREE.Sprite(
    new THREE.SpriteMaterial({
      map: glowMap,
      color,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
      fog: false, // glows shine through the fog
    }),
  );
  sprite.scale.setScalar(size);
  return sprite;
};

// ---- 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");
const engineGlow = glowSprite(PALETTE.flame, 1.6);
engineGlow.position.z = 1.5;
ship.add(engineGlow);

// The shield: a see-through cyan bubble around the ship.
const bubble = new THREE.Mesh(
  new THREE.SphereGeometry(1, 24, 16),
  // A small shader: the bubble glows at its edges, where we look along
  // its surface (the "fresnel" effect), and is clear in the middle.
  new THREE.ShaderMaterial({
    uniforms: {
      color: { value: new THREE.Color(PALETTE.cyan) },
      strength: { value: 1 },
    },
    vertexShader: `
      varying vec3 vNormal;
      varying vec3 vToCamera;
      void main() {
        vec4 seen = modelViewMatrix * vec4(position, 1.0);
        vNormal = normalize(normalMatrix * normal);
        vToCamera = normalize(-seen.xyz);
        gl_Position = projectionMatrix * seen;
      }`,
    fragmentShader: `
      uniform vec3 color;
      uniform float strength;
      varying vec3 vNormal;
      varying vec3 vToCamera;
      void main() {
        float edge = pow(1.0 - abs(dot(vNormal, vToCamera)), 2.5) * strength;
        gl_FragColor = vec4(color * edge, edge);
      }`,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  }),
);
bubble.name = "shield";
bubble.scale.set(2.1, 1.1, 2.1);
ship.add(bubble);
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);
  wakeAudio(); // a key press may start sound
  if (event.code === "Enter") overlayAction();
  if (event.code === "Space") restart();
  if (event.code === "KeyP" || event.code === "Escape") {
    setPaused(state === "playing");
  }
  if (event.code === "KeyM") toggleSound();
});
window.addEventListener("keyup", (event) => held.delete(event.code));
// When the game loses focus we never hear the keyup, so forget all keys,
// and pause, so nobody crashes while they are away.
window.addEventListener("blur", () => {
  held.clear();
  setPaused(true);
});
document.addEventListener("visibilitychange", () => {
  if (document.hidden) setPaused(true);
});

// ---- 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";
  const warning = glowSprite(0xff3a2a, 4); // red, while it is far away
  warning.name = "warning";
  rock.add(warning);
  return rock;
});

function launchRock(rock) {
  const { aimChance, speed: levelSpeed } = difficulty(level);
  const aimed = Math.random() < aimChance;
  const speed = levelSpeed * (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);
  for (const target of spreadTargets(aimAt, spreadTime > 0)) {
    const bullet = freeOne(bullets);
    if (!bullet) break;
    switchOn(bullet, from, aimVelocity(from, target, BULLET_SPEED));
    bullet.life = BULLET_LIFE;
    bullet.mesh.lookAt(target.x, target.y, target.z); // point the capsule where it flies
  }
  tone(1200, 400, 0.07, "square", 0.03); // pew
}

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

// ---- A friendly UFO: do not shoot it! ----
function buildUfo() {
  const ufo = new THREE.Group();
  ufo.name = "ufo";
  const saucer = new THREE.Mesh(
    new THREE.SphereGeometry(1.2, 16, 8),
    new THREE.MeshStandardMaterial({
      color: PALETTE.hull,
      flatShading: true,
      metalness: 0.4,
    }),
  );
  saucer.scale.y = 0.28;
  const dome = new THREE.Mesh(
    new THREE.SphereGeometry(0.5, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2),
    new THREE.MeshStandardMaterial({
      color: PALETTE.ice,
      transparent: true,
      opacity: 0.7,
    }),
  );
  dome.position.y = 0.15;
  ufo.add(saucer, dome, glowSprite(PALETTE.cyan, 4));
  // A ring of little lights around the rim.
  for (let i = 0; i < 8; i++) {
    const light = new THREE.Mesh(
      new THREE.SphereGeometry(0.08, 6, 4),
      new THREE.MeshBasicMaterial({
        color: i % 2 ? PALETTE.cyan : PALETTE.amber,
      }),
    );
    const angle = (i / 8) * Math.PI * 2;
    light.position.set(Math.cos(angle) * 1.15, 0, Math.sin(angle) * 1.15);
    ufo.add(light);
  }
  return ufo;
}
const ufo = { mesh: buildUfo(), active: false };
ufo.mesh.visible = false;
scene.add(ufo.mesh);
let ufoTimer = UFO_EVERY;

function updateUfo(dt) {
  if (state === "playing") ufoTimer -= dt;
  if (ufoTimer <= 0 && !ufo.active) {
    ufoTimer = UFO_EVERY;
    const path = ufoPath(Math.random, bounds);
    switchOn(ufo, path.start, path.velocity);
    ufo.hit = false;
  }
  if (!ufo.active) return;
  drift(ufo, dt);
  ufo.mesh.rotation.y += 2 * dt;
  if (
    Math.abs(ufo.mesh.position.x) > bounds.halfWidth * 5 ||
    ufo.mesh.position.y > 40
  ) {
    switchOff(ufo);
  }
}

// Shot the UFO: points lost, combo gone, and it flees upward.
function ufoShot() {
  ufo.hit = true;
  ufo.velocity = { x: ufo.velocity.x, y: 18, z: 0 };
  score = Math.max(0, score - UFO_PENALTY);
  combo = 1;
  popup(`-${UFO_PENALTY}`, ufo.mesh.position);
  tone(300, 90, 0.5, "square", 0.06);
}

// ---- Power-up orbs: catch one for a spread shot ----
const orbShape = new THREE.IcosahedronGeometry(0.5, 0);
const orbs = makePool(3, () => {
  const orb = new THREE.Mesh(
    orbShape,
    new THREE.MeshBasicMaterial({ color: PALETTE.amber, wireframe: true }),
  );
  orb.name = "orb";
  orb.add(glowSprite(PALETTE.flame, 2.5));
  return orb;
});
let spreadTime = 0; // seconds of spread shot left

function dropOrb(position) {
  const orb = Math.random() < ORB_CHANCE && freeOne(orbs);
  if (!orb) return;
  // It flies toward where the ship is now: easy to catch, if you want it.
  switchOn(orb, position, aimVelocity(position, ship.position, ORB_SPEED));
}

function updateOrbs(dt) {
  spreadTime = Math.max(0, spreadTime - dt);
  for (const orb of activeIn(orbs)) {
    const from = orb.mesh.position.clone();
    drift(orb, dt);
    orb.mesh.rotation.x += 3 * dt;
    orb.mesh.rotation.y += 2 * dt;
    if (
      state === "playing" &&
      sweptHit(from, orb.mesh.position, 0.6, ship.position, SHIP_SPHERES)
    ) {
      switchOff(orb);
      spreadTime = SPREAD_TIME;
      popup("SPREAD!", ship.position);
      tone(500, 1500, 0.3, "triangle", 0.07);
    } else if (orb.mesh.position.z > DESPAWN_Z) {
      switchOff(orb);
    }
  }
}

// ---- The combo banner: "COMBO x3!" for a moment ----
let bannerTime = 0;
function banner(text) {
  const element = document.querySelector("#banner");
  element.textContent = text;
  element.hidden = false;
  bannerTime = BANNER_TIME;
}

// ---- Light flashes: one light that jumps to each explosion ----
const flash = new THREE.PointLight(PALETTE.amber, 0, 40);
scene.add(flash);
// Fewer flashes for people who asked their system for less motion.
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function flashAt(position) {
  flash.position.copy(position);
  flash.intensity = calm ? FLASH_POWER * 0.2 : FLASH_POWER;
}

// ---- "+10" popups: a few HTML spans, reused ----
const popupLayer = document.querySelector("#popups");
const popups = Array.from({ length: 8 }, () => {
  const span = document.createElement("span");
  span.hidden = true;
  popupLayer.append(span);
  return { span, life: 0, x: 0, y: 0 };
});
let nextPopup = 0;
const onScreen = new THREE.Vector3(); // reused

function popup(text, position) {
  onScreen.copy(position).project(camera); // 3D -> -1..1 on screen
  const at = toScreen(
    onScreen.x,
    onScreen.y,
    window.innerWidth,
    window.innerHeight,
  );
  const item = popups[nextPopup];
  nextPopup = (nextPopup + 1) % popups.length;
  Object.assign(item, { life: POPUP_LIFE, x: at.x, y: at.y });
  item.span.textContent = text;
  item.span.hidden = false;
}

function updatePopups(dt) {
  for (const item of popups) {
    if (item.life <= 0) continue;
    item.life -= dt;
    const rise = (POPUP_LIFE - item.life) * 60; // float up 60 px per second
    item.span.style.transform = `translate(${item.x}px, ${item.y - rise}px)`;
    item.span.style.opacity = fade(item.life, POPUP_LIFE);
    if (item.life <= 0) item.span.hidden = true;
  }
}

// ---- Sound: a small synthesizer made with the Web Audio API ----
// Browsers allow sound only after a key press or click, so the
// AudioContext is made (or woken up) then, not when the page loads.
let audio = null;
let muted = false;
let noiseBuffer = null;
let hum = null; // the engine: an oscillator that keeps running

function wakeAudio() {
  if (!audio && window.AudioContext) {
    audio = new window.AudioContext();
    const seconds = audio.sampleRate; // one second of noise, reused
    noiseBuffer = audio.createBuffer(1, seconds, audio.sampleRate);
    noiseBuffer.getChannelData(0).set(noiseSamples(seconds, Math.random));
    startHum();
  }
  if (audio && audio.state === "suspended") audio.resume();
}

// A tone that slides from one pitch to another and fades out.
function tone(fromHz, toHz, seconds, wave, volume) {
  if (!audio || muted) return;
  const now = audio.currentTime;
  const oscillator = audio.createOscillator();
  const level = audio.createGain();
  oscillator.type = wave; // "square" sounds retro, "sine" soft
  oscillator.frequency.setValueAtTime(fromHz, now);
  oscillator.frequency.exponentialRampToValueAtTime(toHz, now + seconds);
  level.gain.setValueAtTime(volume, now);
  level.gain.exponentialRampToValueAtTime(0.001, now + seconds);
  oscillator.connect(level).connect(audio.destination);
  oscillator.start(now);
  oscillator.stop(now + seconds);
}

// A burst of noise through a low-pass filter: a muffled boom.
function boom(seconds, volume, cutoffHz) {
  if (!audio || muted) return;
  const now = audio.currentTime;
  const source = audio.createBufferSource();
  source.buffer = noiseBuffer;
  const filter = audio.createBiquadFilter();
  filter.type = "lowpass";
  filter.frequency.setValueAtTime(cutoffHz, now);
  const level = audio.createGain();
  level.gain.setValueAtTime(volume, now);
  level.gain.exponentialRampToValueAtTime(0.001, now + seconds);
  source.connect(filter).connect(level).connect(audio.destination);
  source.start(now);
  source.stop(now + seconds);
}

function startHum() {
  const oscillator = audio.createOscillator();
  oscillator.type = "sawtooth";
  const filter = audio.createBiquadFilter();
  filter.type = "lowpass";
  filter.frequency.value = 280;
  const level = audio.createGain();
  level.gain.value = 0;
  oscillator.connect(filter).connect(level).connect(audio.destination);
  oscillator.start();
  hum = { oscillator, level };
}

function updateHum(speed) {
  if (!hum) return;
  const { frequency, volume } = engineHum(speed);
  const on = !muted && state === "playing";
  // setTargetAtTime glides to the new value, so the hum never clicks.
  hum.oscillator.frequency.setTargetAtTime(frequency, audio.currentTime, 0.1);
  hum.level.gain.setTargetAtTime(on ? volume : 0, audio.currentTime, 0.1);
}

function toggleSound() {
  muted = !muted;
  document.querySelector("#sound").textContent = muted
    ? "SOUND: OFF"
    : "SOUND: ON";
}

// ---- Game state: "intro", "countdown", "playing", "paused" or "over" ----
let state = "intro";
let introFade = 0; // seconds left of the title fading away
let countdownLeft = COUNTDOWN; // seconds
let survived = 0; // seconds
let score = 0;
let kills = 0;
let level = 1;
let combo = 1;
let lastKill = -Infinity; // game time of the last kill
let shield = true;
let safeTime = 0; // seconds of safety left after the shield took a hit
const getStorage = () => window.localStorage;
let highscore = loadHighscore(getStorage);

// ---- HUD and overlay: plain HTML on top of the canvas ----
// textContent (not innerHTML) puts text in, never markup.
const $ = (selector) => document.querySelector(selector);
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;
  let text = `SCORE ${score} · LEVEL ${level} · HIGHSCORE ${Math.max(score, highscore)}`;
  if (score > highscore) {
    highscore = score;
    saveHighscore(getStorage, highscore); // fine if it fails: we keep it here
    text += " · NEW HIGHSCORE!";
    banner("NEW HIGHSCORE!");
  }
  $("#overlay-small").textContent = "OR PRESS ENTER";
  overlay.classList.remove("intro");
  showOverlay("GAME OVER", text, "PLAY AGAIN");
  flashAt(ship.position);
  boom(1.2, 0.35, 500);
}

// A rock hit the ship: the shield takes the first hit, the second ends it.
function shipHit(rock) {
  if (!shield) return gameOver();
  shield = false;
  safeTime = SAFE_TIME;
  switchOff(rock);
  explode(rock.mesh, 40, hexToRgb(PALETTE.cyan), rock.velocity);
  flashAt(rock.mesh.position);
  tone(700, 120, 0.4, "sine", 0.15);
  boom(0.4, 0.2, 1200);
}

// A bullet broke a rock: count it, score it (with the combo), level up.
function rockShot(rock) {
  switchOff(rock);
  explode(rock.mesh, 40, hexToRgb(SLATE[2]), rock.velocity);
  combo = nextCombo(combo, survived - lastKill);
  lastKill = survived;
  score += POINTS_PER_ROCK * combo;
  if (combo > 1) banner(`COMBO x${combo}!`);
  dropOrb(rock.mesh.position.clone());
  popup(`+${POINTS_PER_ROCK * combo}`, rock.mesh.position);
  flashAt(rock.mesh.position);
  boom(0.45, 0.25, 900);
  kills += 1;
  const before = level;
  level = levelFor(kills);
  if (level > before) {
    tone(440, 880, 0.3, "square", 0.05); // level up!
    banner(`LEVEL ${level}`);
  }
}

// Back to the start: clear the rocks, ship at its start, count down.
// The console's RESTART button may do this at any time (force).
function restart(force = false) {
  if (state !== "over" && state !== "intro" && !force) return;
  if (state === "intro") introFade = INTRO_FADE; // let the title fade out
  rocks.forEach(switchOff);
  bullets.forEach(switchOff);
  orbs.forEach(switchOff);
  switchOff(ufo);
  ufoTimer = UFO_EVERY;
  spreadTime = 0;
  burst = { left: 0, wait: 0 };
  score = 0;
  kills = 0;
  level = 1;
  combo = 1;
  lastKill = -Infinity;
  shield = true;
  safeTime = 0;
  target = startTarget(bounds);
  ship.position.set(target.x, target.y, 0);
  ship.visible = true;
  survived = 0;
  spawnTimer = 0;
  countdownLeft = COUNTDOWN;
  state = "countdown";
  if (introFade === 0) overlay.hidden = true;
}

// P, Escape or leaving the game window: pause.
function setPaused(paused) {
  if (paused !== (state === "playing")) return; // nothing to switch
  state = togglePause(state);
  held.clear();
  if (paused) {
    $("#overlay-small").textContent = "OR PRESS P";
    showOverlay("PAUSED", "TAKE A BREATH", "RESUME");
  } else {
    overlay.hidden = true;
  }
}

// The overlay button and Enter: restart after GAME OVER, else resume.
function overlayAction() {
  if (state === "over" || state === "intro") restart();
  else setPaused(false);
}
$("#overlay-button").addEventListener("click", overlayAction);
$("#sound").addEventListener("click", (event) => {
  event.currentTarget.blur();
  toggleSound();
});
// Fullscreen, where the browser allows it (not inside every frame).
const fullscreenButton = $("#fullscreen");
fullscreenButton.hidden = !document.fullscreenEnabled;
fullscreenButton.addEventListener("click", (event) => {
  event.currentTarget.blur();
  if (document.fullscreenElement) document.exitFullscreen();
  else document.documentElement.requestFullscreen().catch(() => {});
});
$("#restart").addEventListener("click", (event) => {
  event.currentTarget.blur(); // or Space (fire!) would press it again
  restart(true);
});
canvas.addEventListener("pointerdown", () => (clicked = true)); // click = fire
window.addEventListener("pointerdown", wakeAudio); // a click may start sound

// 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;
  engineGlow.scale.setScalar(1.4 + Math.random() * 0.4);
}

function updateRocks(dt) {
  spawnTimer -= dt;
  const launching = state === "playing"; // no new rocks during 3-2-1
  const { maxRocks, spawnEvery } = difficulty(level);
  if (launching && spawnTimer <= 0 && activeIn(rocks).length < maxRocks) {
    spawnTimer = spawnEvery;
    launchRock(freeOne(rocks));
  }
  for (const rock of activeIn(rocks)) {
    const from = rock.mesh.position.clone();
    drift(rock, dt);
    if (state === "playing" && safeTime === 0 && rockHitsShip(rock, from)) {
      shipHit(rock);
    }
    rock.mesh.rotation.x += rock.spin.x * dt;
    rock.mesh.rotation.y += rock.spin.y * dt;
    rock.mesh.children[0].material.opacity = warningGlow(rock.mesh.position.z);
    // 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);
      rockShot(hit);
    }
    // Hitting the friendly UFO costs points.
    const ufoAt = ufo.mesh.position;
    if (
      bullet.active &&
      ufo.active &&
      !ufo.hit &&
      segmentDistance(from, bullet.mesh.position, ufoAt) < UFO_RADIUS
    ) {
      switchOff(bullet);
      ufoShot();
    }
  }
}

// The flame trail: a few orange pixels from the engine every frame.
let trailDue = 0; // pixels owed (a frame can be worth half a pixel)
function updateTrail(dt) {
  trailDue += TRAIL_RATE * dt;
  for (; trailDue >= 1; trailDue -= 1) {
    const particle = particles[nextParticle];
    particle.velocity = {
      x: (Math.random() - 0.5) * 1.5,
      y: (Math.random() - 0.5) * 1.5,
      z: 8 + Math.random() * 4, // backward, toward the camera
    };
    particle.life = particle.maxLife = 0.25 + Math.random() * 0.15;
    particle.rgb = hexToRgb(
      Math.random() < 0.5 ? PALETTE.flame : PALETTE.amber,
    );
    const { x, y, z } = ship.position;
    particlePositions.set([x, y, z + 1.5], nextParticle * 3);
    nextParticle = (nextParticle + 1) % PARTICLE_POOL;
  }
}

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

// After a hit on the shield the ship blinks while it is safe.
function updateShield(dt) {
  safeTime = Math.max(0, safeTime - dt);
  if (state !== "over") ship.visible = Math.floor(safeTime * 10) % 2 === 0;
  bubble.visible = shield;
}

// The console along the bottom, and the combo text at the top.
function updateConsole() {
  if (survived - lastKill > COMBO_WINDOW) combo = 1; // too slow: no combo
  scoreText.textContent = score;
  $("#level").textContent = level;
  $("#kills").textContent = kills;
  $("#highscore").textContent = Math.max(score, highscore);
  $("#shield").classList.toggle("on", shield);
  $("#spread").classList.toggle("on", spreadTime > 0);
  $("#combo").textContent = combo > 1 ? `COMBO x${combo}` : "";
}

function updateDust(dt) {
  for (let i = 2; i < dustPositions.length; i += 3) {
    dustPositions[i] = wrapDepth(dustPositions[i], dt, DUST_SPEED, 12, -60);
  }
  dustGeometry.attributes.position.needsUpdate = true;
}

// Step 8 extras on screen: the fading title, the banner, the shield glow.
function updateScreens(dt) {
  // The title screen fades out instead of vanishing at once.
  if (introFade > 0) {
    introFade = Math.max(0, introFade - dt);
    overlay.style.opacity = fade(introFade, INTRO_FADE);
    if (introFade === 0) {
      overlay.hidden = true;
      overlay.style.opacity = "";
      overlay.classList.remove("intro");
    }
  }
  bannerTime = Math.max(0, bannerTime - dt);
  if (bannerTime === 0) $("#banner").hidden = true;
  // The shield's edge glow pulses gently.
  bubble.material.uniforms.strength.value = 0.8 + Math.sin(survived * 4) * 0.2;
}

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

  // Paused: draw the same picture again, change nothing.
  if (state === "paused") {
    updateHum(0); // state is "paused", so this silences the engine
    renderer.render(scene, camera);
    return;
  }

  // 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);
  updateDust(dt);
  galaxy.rotation.z += 0.004 * 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);
  updateUfo(dt);
  updateOrbs(dt);
  if (state !== "over") updateTrail(dt);
  updateParticles(dt);
  updatePopups(dt);
  updateHum(Math.hypot(velocity.x, velocity.y));
  flash.intensity = decay(flash.intensity, FLASH_DECAY, dt);
  updateShield(dt);
  updateConsole();
  updateScreens(dt);
  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. Step 8 adds no new technique as big as sound or collisions; what it shows is how new ideas fit into code that already works.

Tuning constants

A tuning constant is a named value at the top of a program that sets how the game behaves (a speed, a chance, a duration), kept in one place so you can adjust the feel without touching the logic. Every new idea in step 8 came with its own:

const UFO_EVERY = 25; // seconds between friendly UFOs
const UFO_Z = -38; // UFOs cross the view this far ahead
const UFO_SPEED = 7;
const UFO_RADIUS = 1.3;
const UFO_PENALTY = 50; // points lost for shooting a friendly UFO
const ORB_CHANCE = 0.1; // chance that a broken rock drops a power-up orb
const ORB_SPEED = 16;
const SPREAD_TIME = 8; // seconds of spread shot after catching an orb
const SPREAD_WIDTH = 4; // sideways distance of the extra shots at the reticle

These are the opposite of the magic numbers from Reading the Code You Got: each has a name and a comment, and you change the game by changing one line. Want UFOs more often? Set UFO_EVERY to 10, reload and play. No prompt needed, and nothing else can break.

New ideas as pure functions

The logic of each idea sits in the rules section as a small pure function: numbers in, numbers out, no Three.js and no page. The spread shot, for example, turns one aim point into three:

// With the spread shot, each shot becomes three: left, middle, right.
function spreadTargets(target, spreading) {
  if (!spreading) return [target];
  return [-SPREAD_WIDTH, 0, SPREAD_WIDTH].map((dx) => ({
    ...target,
    x: target.x + dx,
  }));
}

The UFO's path works the same way, with its random choices passed in as a function (ufoPath(random, bounds)). While testing the reference game this way, one bug turned up that playing might never have shown: UFOs could fly above the height the ship can reach, so they could never be hit. They now cross at a reachable height.

Restart resets everything

Every idea adds state: a timer, a flag, objects that are switched on. Restart has to put all of it back, or the next game starts with leftovers. These are the step 8 lines that reset the new ideas:

  orbs.forEach(switchOff);
  switchOff(ufo);
  ufoTimer = UFO_EVERY;
  spreadTime = 0;

When you read your own AI's answer, this is a good place to check: for each new variable in the answer, find the line that resets it.

The shield shader

The glowing shield is a ShaderMaterial: two tiny programs that run on the graphics card. The second one makes each point of the bubble brighter the more you look along its surface, so the edges glow and the middle stays clear. Shaders are written in their own language (GLSL, the OpenGL Shading Language), inside a JavaScript string, so the JavaScript tools cannot check them in advance. A mistake shows up only when the browser compiles the shader, as a compile error in the console and a black or white shape on screen. Only a real browser with WebGL can show whether a shader works.

Playtesting and game feel

Playtesting is playing a game, or watching other people play it, to find out whether it is fun, fair and understandable, and noting what to change. It is a form of exploratory testing: you learn while you test. Watching someone else play for the first time is usability testing: keep quiet, and note where they hesitate.

What you are judging is mostly game feel: how responsive and satisfying the controls and feedback are, from how quickly the ship follows the mouse to how a broken rock looks and sounds. Game feel lives in numbers, which is why it is tuned rather than programmed. Some questions for a session:

  • Is level 1 too easy or too hard for someone who has never played? (EASY in the rules section.)
  • Does the ship feel heavy or twitchy? (SHIP_FOLLOW.)
  • Do you know why you lost? A loss you understand is fair; one you did not see coming is not.
  • Is any sound annoying after five minutes? Is any flash too much?

Change one constant, reload, play again. Two changes at once and you no longer know which one helped.

A final checklist

Before you call the game done, or show it to anyone:

  • It works after a fresh reload, not only after the edit you just made.
  • The console is clean: no errors and no warnings (a warning about THREE.Clock, for example, means old code; see How an AI Model Writes Code).
  • It plays the same at different speeds, on a 60 Hz screen and a faster one, because all movement uses delta time.
  • Every control works: keyboard, mouse and, if you added it, touch; restart and pause reset and freeze everything.
  • You can explain every part. Simon Willison's rule for his own projects is "I won't commit any code to my repository if I couldn't explain exactly what it does to somebody else" (Not all AI-assisted programming is vibe coding, 19 March 2025). For a game you share, that is a good bar too. Where you cannot, ask the AI to walk you through that part (see Reading the Code You Got).
  • Nothing private is in it: no keys, passwords, email addresses or names that are not meant to be public.

Sharing what you made

On CodePen's free plan (as of October 2026) the options are these:

  • Public Pen. Every Pen on a free account is public: anyone can see it, and public Pens are MIT licensed, so others may reuse the code. Private Pens need a PRO plan.
  • Export. Any logged-in visitor can export a Pen as a .zip file with the source files, the last build and the license. It is also the way out of CodePen, toward Git and real tooling (see From Prototype to Real Software).
  • Embed. A Pen can be embedded in another web page for free; removing CodePen's branding from the embed is a PRO feature, and so is a live preview URL of the result on its own.