What you add

The starfield from step 1 gets a pilot: a small jet that waits in the lower middle of the screen, seen from behind. It is built from simple Three.js shapes, follows the pointer smoothly, rolls into sideways moves and lifts its nose when it climbs. This is the first step that changes a working program instead of starting a new one, so it is also the first real test of prompting in small steps: one feature, described precisely, on top of a version that works.

  • The ship, built from about twenty simple shapes.
  • Steering with the mouse, a finger, the arrow keys and WASD.
  • Smooth movement that feels the same on a 60 Hz and a 144 Hz screen.
  • A help line at the bottom of the screen.

Before you start

Check that your step 1 Pen works (stars streaming, no errors in the console), then save it and copy the contents of script.js into a text file. If this step goes wrong, that copy is your way back. Better ways to keep versions are on Saving Versions and Going Back.

The prompt

Open a new chat, or continue the one from step 1. Copy the prompt below and replace its last line, [paste index.html, style.css and script.js here], with the current contents of your three files. Sending the code along matters: the assistant cannot see your Pen, and the files you paste are the only version of the game it knows. Then replace all three files with the answer.

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 2: a ship you can fly.

The ship, a "retro jet", built from Three.js geometries in one THREE.Group
named "ship", nose pointing ahead (negative z), low-poly with flatShading:
- a cream body (#e8e2d0) made with LatheGeometry from a side profile,
- a teal nose cone (#1f7a8c), teal swept wings made with Shape +
  ExtrudeGeometry, and a teal tail fin,
- two copper rings around the body (#b87333), a glass cockpit canopy
  (half a sphere, see-through, icy #d8f7ff),
- thin neon cyan lines (#7fe3f5) along the body and the wings,
- a small dark gun barrel on each wing tip,
- an engine bell at the back with an orange flame cone (#ff7a1a) that
  uses additive blending and flickers in length.
Add an ambient light, a white directional light and a cyan rim light.

Flying:
- The ship stays at z = 0 and follows the mouse pointer: turn the pointer
  position into a target point at the ship's depth, and ease toward it
  (exponential easing that feels the same at any frame rate).
- Before the first mouse or key input, the ship waits in the lower middle
  of the screen: centered, halfway between the middle and the bottom.
- Keep the target inside the visible area minus a margin, so the wing tips
  never leave the screen. Recompute these bounds on resize.
- The arrow keys and WASD also move the target. Use event.code, and call
  preventDefault for the arrow keys and Space so the page never scrolls.
  Forget all held keys when the window loses focus (blur).
- The ship rolls when it moves sideways and pitches its nose up when it
  climbs, by an amount based on its speed, with a maximum.
- Make the canvas touch-action: none, so a finger can steer too.
- Keep the starfield.
- Add a hint at the bottom: "MOVE THE MOUSE (OR ARROWS / WASD) TO FLY".

Keep the pure logic (the bounds, pointer to target, start point, easing,
tilt, key direction) in small functions without Three.js or the DOM in
the rules section at the top. Comment for a beginner.

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

Notice how much of the prompt is about how: exponential easing "that feels the same at any frame rate", event.code, preventDefault, the logic in small functions at the top. Left out, each of these is a decision the model makes for you, often in the way older tutorials did it. The section How it works below explains what each one does.

What you should see

  • Before you touch the mouse or the keys: a cream-and-teal jet in the lower middle of the screen, seen from behind and a little above.
  • A flickering orange flame at its tail.
  • Move the mouse: the jet glides after it, not jerkily, and stops smoothly.
  • Move fast to the right: the jet rolls to the right (right wing down), then levels out.
  • Move up: the nose lifts a little.
  • Push the pointer to every edge: the wing tips stay on screen.
  • Click the game once, then try the arrow keys and WASD: the jet moves and the page does not scroll.
  • Stars still stream past.

If it goes wrong

  • The keys do nothing: click inside the game first. Keys only go to the focused frame, and CodePen's preview is a frame of its own.
  • The jet flies backward or is upside down: the nose must point to negative z. Ask the AI which way each part was rotated, and to fix the rotation, not to add extra rotations on top.
  • Wings look black or invisible from one side: the material may need side: THREE.DoubleSide, or the extruded shape is facing away. Ask the AI to explain which side is drawn.
  • The jet leaves the screen on a wide or narrow preview: the bounds must be computed from the camera's field of view and aspect ratio, and updated in the resize handler.
  • The ship moves faster on a 144 Hz screen: the easing ignores delta time. Ask for easing with Math.exp(-rate * dt).
  • The AI changed the camera or removed the stars: say "keep everything that already works" again, and paste your last working version back if needed.

The reference version

Below is one possible result of this step: the tested reference version. Press Play to run it right here; the frame takes the keyboard focus when it starts, and if the keys do not respond, click in the game once. The code under the frame is the three parts to paste into a Pen: the HTML goes inside <body>, then the CSS and the JavaScript. This frame is sandboxed, so a few things behave differently from CodePen: it has no network access (external fonts are not loaded; the site supplies the pixel font itself) and no storage, and in later steps the high score is not remembered and the fullscreen button is hidden.

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

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

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

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

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

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

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

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

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

// The ship's colors (the same values as the CSS custom properties).
const PALETTE = {
  hull: 0xe8e2d0,
  teal: 0x1f7a8c,
  cyan: 0x7fe3f5,
  cyanDeep: 0x2fa8c0,
  ice: 0xd8f7ff,
  copper: 0xb87333,
  amber: 0xffc890,
  flame: 0xff7a1a,
  metal: 0x2e3036,
};
const SHIP_FOLLOW = 6; // how quickly the ship catches up (higher = snappier)
const KEY_SPEED = 9; // units per second when steering with the keys
const WING_MARGIN = 1.9; // keep the wing tips on screen
const ROLL = 0.09; // radians of roll per unit/second of sideways speed
const PITCH = 0.05; // radians of pitch per unit/second of climb
const MAX_TILT = 0.7; // never tilt more than this (radians)
// 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 };
}
// ---- 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);
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;
const stars = new THREE.Points(starGeometry, starMaterial);
stars.frustumCulled = false; // the stars move, so always draw them
scene.add(stars);

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

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

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

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

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

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

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

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

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

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

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

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

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

  // Speeds are "per second" times dt, so the game runs at the same pace
  // on a 60 Hz and a 144 Hz screen.
  updateStars(dt);
  updateShip(dt);

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

How it works

The script has grown from about 110 to about 400 lines, most of it the ship. Four ideas carry the step; the excerpts are copied from the reference script.js. How to find your way around a file this size is the subject of the next page, Reading the Code You Got.

A ship from shapes

A mesh is a visible 3D object in Three.js: a geometry (the shape, as a set of triangles) combined with a material (how its surface reacts to light: color, shininess, transparency). The jet is about twenty meshes in one THREE.Group, so moving or turning the group moves the whole ship. The helper that adds each part (inside buildShip()) shows the pattern:

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

The body is a LatheGeometry: a side profile turned around an axis, like wood on a lathe. Wings and fin are flat outlines (Shape) given thickness with ExtrudeGeometry. flatShading: true gives every face one flat color, which is what makes it look low-poly. Most parts use MeshStandardMaterial, which needs light, so the scene gets three lights: a soft ambient light, a white "sun" and a cyan rim light from ahead. The neon lines use MeshBasicMaterial, which ignores the lights and always shows its own color, so they seem to glow.

The animation loop

An animation loop is the function a game runs once for every frame the screen shows: it reads the input, updates the state of the world and draws the new picture. Browsers call it at about the display's refresh rate, for example 60, 120 or 144 times per second, and pause it in most browsers when the tab or frame is hidden. In Three.js you hand that function to renderer.setAnimationLoop(). Input does not happen inside the loop: event listeners record it whenever it arrives (the pointer's target, the keys held down), and the loop reads that record each frame.

The game loop of step 2. Event listeners run whenever input arrives: pointermove sets the target, keydown and keyup update the set of held keys, blur clears it. Once per frame the loop measures delta time, updates the stars and the ship by speed times delta time using the held keys and the target, and renders the scene. Then it waits for the next frame. Event listeners pointermove, keydown, keyup, blur Recorded input target, held keys any time Measure dt Update speed × dt Render once per frame (60, 120, 144 times a second) read each frame
Listeners record input whenever it happens; the loop reads it once per frame, updates by delta time and draws.

Delta time and easing

Delta time (often dt) is the time that passed since the previous frame, usually in seconds. Multiplying every speed by it ("units per second times seconds") makes movement independent of the frame rate. MDN warns that an animation that ignores this "will run faster on high refresh-rate screens"; a runnable example on Prompting in Small Steps shows the difference: 30 units in one second at 60 Hz, 72 units at 144 Hz.

The ship does not jump to the pointer; it eases toward a target point. Easing has the same trap: "move 10% of the remaining gap each frame" means more steps, and so a faster ship, on a faster screen. The reference version uses exponential easing, in which the time step sits in the exponent:

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

Whether that comment is true is something you can check rather than believe; the next page, Reading the Code You Got, runs this function at both frame rates. The ship's roll and pitch come from its speed (tilt()), capped at 0.7 radians so it never flips over.

Keys with event.code

KeyboardEvent.code is the property of a keyboard event that names the physical key that was pressed, such as "KeyW", "ArrowLeft" or "Space", regardless of the keyboard layout. MDN notes it is "especially common when writing code to handle input for games". Its sibling event.key gives the character the key produces in the current layout, so a game that checks key === "w" breaks on a French AZERTY keyboard, where that key types "z". Older code uses the numeric event.keyCode; prefer code.

const held = new Set();
window.addEventListener("keydown", (event) => {
  if (event.code.startsWith("Arrow") || event.code === "Space") {
    event.preventDefault();
  }
  held.add(event.code);
});
window.addEventListener("keyup", (event) => held.delete(event.code));
// When the game loses focus we never hear the keyup, so forget all keys.
window.addEventListener("blur", () => held.clear());

The listeners only keep a set of the keys held down; the loop turns that set into a direction each frame (keyDirection()). preventDefault() stops the arrow keys and Space from scrolling the page around the game (see default actions), and the blur listener prevents a ship that keeps flying left forever because you released the key while another window had the focus. Listeners are added once, at the start, never inside the loop.