Why read code you did not write

In the original sense, vibe coding means not reading the code at all. For a throwaway game that is a legitimate choice, and nothing on this page makes it wrong. But after two build steps the game's script is about 400 lines, and the next steps add collisions, shooting and scores on top. If you cannot find your way around it, every problem goes back to the AI with "it doesn't work", and you depend completely on its next guess.

Simon Willison put the line between the two ways of working sharply: "If an LLM wrote the code for you, and you then reviewed it, tested it thoroughly and made sure you could explain how it works to someone else that's not vibe coding, it's software development" (Not all AI-assisted programming is vibe coding, March 2025). Reading is the first of those three, and it is less work than it sounds: you are not checking every line, you are building a map. Confidence is no substitute. In a Stanford study (Perry et al., 2023), people who wrote code with an AI assistant wrote less secure code than those without one, and were also more likely to believe their code was secure.

A first walkthrough

A code walkthrough is going through a program in a planned order to understand its structure and flow, before (or instead of) studying every line. For a browser game, the order that works is the order in which things happen. Almost every Three.js game an assistant writes has the same sections, and the reference script of this topic marks them with comments such as // ---- The animation loop ----:

The sections of the game's script.js from top to bottom: the import line; the game rules, with constants and pure functions; the renderer, scene, camera and lights; the objects, such as the stars and the ship; the event listeners for pointer, keys and resize; and the animation loop. The first five sections run once when the page loads; the listener functions run whenever input arrives; the animation loop runs every frame. import * as THREE from "three" Game rules: constants, pure functions Renderer, scene, camera, lights Objects: stars, ship Listeners: pointer, keys, resize Animation loop: measure dt, update stars and ship, render runs once, when the page loads listener bodies: on each event runs every frame
The map of the step 2 script: most of the file is setup; the part that runs 60 or more times a second is short.

A walkthrough of a file like this takes four passes:

  1. Read only the section comments and function names. That gives you the map above. If the AI's code has no sections, asking it to "add a one-line comment above each section" is a harmless first prompt.
  2. Find the loop. Search for setAnimationLoop (or requestAnimationFrame in other code). Everything called from it runs every frame; that is where speed, movement and most bugs live.
  3. Follow one feature end to end. For the keys in step 2: the keydown listener adds to held, keyDirection() turns held into a direction, updateShip() moves the target. Three places, one story.
  4. Change a value and watch. Double SHIP_FOLLOW and the ship gets snappier. Watching a change happen confirms your map faster than reading does.

The JavaScript behind it is covered in the JavaScript topic, in particular functions, the DOM and events and ES modules.

Names and numbers

A magic number is a literal number in code whose meaning is not explained by a name or comment, such as 0.4 in flame.scale.z = 0.8 + Math.random() * 0.4. It works, but you cannot tell what it means, whether the same value is used elsewhere, or which one to change when you want the game slower. The cure is a named constant at the top of the file, which the reference version mostly uses:

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)

"Mostly", because even the reference has plenty, from light strengths to the ship's dimensions; among them are the flame's flicker range (0.8 and 0.4) and two margins in shipBounds() (+ 0.9 and - 1.3). For a decoration that is harmless; for a value you will tune, lift it into a named constant. Gathering the tuning values in one place is what makes the last build step, tuning the game to your taste, practical. (The same habit in Python: no magic numbers.)

Names carry the rest of the meaning. A naming convention is a consistent rule for how names are written, so the form of a name tells you what kind of thing it is. The reference script uses the common JavaScript convention: UPPER_SNAKE_CASE for fixed settings (STAR_COUNT, MAX_STEP), camelCase for variables and functions (starPositions, streamStar()), and PascalCase for classes (THREE.Mesh). See style conventions and constants. When an AI answer mixes conventions (ship_speed next to shipSpeed), it often means code from two answers was pasted together, and there may now be two variables where you expected one.

What runs every frame

Code inside the animation loop runs 60 or more times per second (144 or more on fast screens), so mistakes there multiply. Two are typical of generated code, and both are invisible at first: the game works fine for a minute, then stutters or behaves strangely.

// What not to do: work that belongs in the setup, inside the loop
renderer.setAnimationLoop(() => {
  // a new listener every frame: after a minute there are thousands
  window.addEventListener("keydown", (event) => held.add(event.code));
  // a new geometry, material and mesh every frame, never freed
  const spark = new THREE.Mesh(
    new THREE.SphereGeometry(0.1),
    new THREE.MeshBasicMaterial({ color: 0xffc890 }),
  );
  scene.add(spark);
  renderer.render(scene, camera);
});
  • Listeners added inside the loop. Each call to addEventListener with a new function adds one more listener; none are replaced. After a minute at 60 frames per second there are about 3,600, all running on every key press. Listeners belong in the setup, added once (see events).
  • Objects created every frame. Every new THREE.SphereGeometry uploads data to the graphics card.

That second point needs a word of its own. Object disposal in Three.js is freeing the memory a geometry, material or texture holds on the graphics card by calling its dispose() method once it is no longer needed. JavaScript's garbage collection does not do this for you: the Three.js manual says these objects "are not released automatically", and that a geometry's memory is only freed if you call dispose(). Removing a mesh from the scene is not enough. Code that creates objects and forgets them has a memory leak. The healthier pattern, which the game uses from step 3 on, is to create the shapes once, share a few geometries and one material between many meshes, and reuse objects instead of creating new ones.

Code smells in AI code

A code smell is a feature of code that is not necessarily a bug but suggests a deeper problem, and so deserves a closer look. The term is old and general; the smells below are the ones that turn up most in AI-written browser games:

  • Deprecated APIs. A model learned from code of many ages and may suggest calls that are outdated: new THREE.Clock() (deprecated since Three.js r183; use THREE.Timer), renderer.useLegacyLights (gone in r186), or the numeric event.keyCode instead of event.code. The console often warns about the first; see How an AI Model Writes Code for why models do this.
  • Mixed versions or CDNs. The Three.js manual's rule is to "import all dependencies from the same version of three.js, and from the same CDN". An answer that imports "three" in one line and an add-on from a different version's URL in the next can load two copies of the library that do not work together.
  • innerHTML for text. Putting a score or a name on screen with innerHTML parses it as HTML; textContent is the safe habit (see textContent and innerHTML).
  • Duplicates. Two functions doing nearly the same thing, or two variables for the same value, usually after an answer was pasted on top of an older one.

Dead code is the most common smell after a few rounds of changes: code that is never run, or whose result is never used. Think of a function the new version no longer calls, a block that was "commented out for now", or a variable that is set and never read. It costs nothing at run time but a lot when reading, because you cannot tell which version is the real one. Before you delete it, though, ask: the step 2 reference defines a color PALETTE.amber that nothing uses yet, because a later step needs it. Removing code that looks dead is a change like any other, so do it in its own step and check the result. Cleaning up structure without changing behavior is called refactoring; smells left alone pile up into technical debt.

Asking the AI to explain

Explaining code is one of the safest things to ask an assistant: nothing changes, and you can check what you learn. Paste the part you do not understand, say who you are, and ask for "explain only" (the general pattern is on Prompting for Code):

Explain only; do not change the code. I am new to JavaScript and building a small Three.js game.

1. In two sentences, what does this function do?
2. Then line by line, in plain words.
3. The comment claims it "feels the same at 60 and at 144 frames per second". Is that true? Why?
4. Say if anything depends on code that is not shown.

// 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);
}
The function moves a value (here the ship's x or y position) part of the way toward a target each frame. How far it moves depends on the time since the last frame, dt, so the motion is smooth and independent of the frame rate.

- (current - target) is the remaining gap.
- Math.exp(-rate * dt) is a number between 0 and 1: the share of the gap that is left after dt seconds. A higher rate shrinks it faster.
- target + gap * share is the new position: closer to the target, never past it.

Yes, the comment is true: over one second the gap is multiplied by e^-rate in total, whether that second has 60 frames or 144. It depends on dt being in seconds; that comes from code that is not shown.

That answer sounds right, and here it is. But a confident tone is no evidence: an explanation can be wrong in the same fluent way as code. Treat it as a claim and check the claim. This function is a pure function, so you can simply run it at both frame rates, next to the "10% of the gap per frame" version an AI might write without delta time:

// From the GAME RULES section of step 2 (script.js), copied unchanged:
// 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);
}

// For comparison: "move 10% of the gap each frame", without delta time.
function followPerFrame(current, target) {
  return current + (target - current) * 0.1;
}

// The ship starts at x = 0; the pointer target is at x = 10.
// Where is the ship after a quarter of a second?
for (const fps of [60, 144]) {
  let eased = 0;
  let perFrame = 0;
  for (let frame = 0; frame < fps / 4; frame++) {
    eased = follow(eased, 10, 6, 1 / fps);
    perFrame = followPerFrame(perFrame, 10);
  }
  console.log(`${fps} Hz: follow ${eased.toFixed(2)}, per frame ${perFrame.toFixed(2)}`);
}
60 Hz: follow 7.77, per frame 7.94
144 Hz: follow 7.77, per frame 9.77

The claim holds: after a quarter of a second the eased ship is at 7.77 on both screens, while the per-frame version is at 7.94 on one and 9.77 on the other. The same trick works for any pure function in the game. Here is the star placement from step 1, fed with fixed numbers instead of Math.random, to check the comment "an empty tube around the flight path" (the distance from the path should never be below 3 or above 70). It also checks the angle math from the unit circle: a point at angle θ and distance r is (r·cos θ, r·sin θ).

// From the GAME RULES section of step 1 (script.js), copied unchanged:
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;

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

// Instead of Math.random, feed fixed numbers: the extremes and a middle.
function fixed(...numbers) {
  return () => numbers.shift();
}

for (const numbers of [[0, 0, 0], [0.25, 1, 1], [0.5, 0.25, 0.5]]) {
  const [x, y, z] = randomStar(fixed(...numbers));
  const fromPath = Math.hypot(x, y); // distance from the flight path (x = y = 0)
  console.log(`${numbers.join(", ")} -> distance ${fromPath.toFixed(1)}, z ${z}`);
}
0, 0, 0 -> distance 3.0, z -190
0.25, 1, 1 -> distance 70.0, z 12
0.5, 0.25, 0.5 -> distance 36.5, z -89

The extremes land exactly on 3 and 70, and on the far and near ends of the tube. The third line shows what Math.sqrt does: a random value of 0.25 puts the star halfway out (36.5), not a quarter of the way, which pushes stars outward where the ring has more room. "Evenly" is close but not exact: near the inner edge the stars are a little thinner than a perfectly even spread would give. Nobody will see that in a starfield, but it is exactly the kind of detail an explanation glosses over and a check reveals.

Try it yourself

Open a new chat in an AI assistant (Claude, ChatGPT, Copilot, Gemini) and paste the first prompt. Then start another new chat and paste the second. The code is the step 1 star placement shown above.

Explain only; do not change the code. What does this JavaScript function return, and why does it use Math.sqrt? Is the comment above that line exactly true?

const STAR_FAR = -190;
const STAR_NEAR = 12;
const STAR_MIN_RADIUS = 3;
const STAR_MAX_RADIUS = 70;

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];
}
Without running it: what does randomStar return when random() returns 0.5, then 0.25, then 0.5? Give x, y and z rounded to one decimal, and show your working.

const STAR_FAR = -190;
const STAR_NEAR = 12;
const STAR_MIN_RADIUS = 3;
const STAR_MAX_RADIUS = 70;

function randomStar(random) {
  const angle = random() * Math.PI * 2;
  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];
}

What to look for: in the first answer, whether it explains the square root correctly (it pushes stars outward, where the ring has more area) and whether it notices that the spread is only nearly even, or simply agrees with the comment. In the second, compare with the third line of the example above, which shows the distance (36.5): an angle of half a turn puts the star straight to the left, so the correct result is x −36.5, y 0.0 and z −89. A model that does arithmetic in its head can get such numbers wrong while showing tidy working, which is exactly why you run pure functions instead of trusting the prediction.

Asking why is also how you learn. In a study Anthropic published in January 2026, people learning a new programming library with AI help scored better on a quiz right afterward when they asked conceptual follow-up questions than when they only let the AI write the code (details on Risks of Vibe Coding).

The explain-back test

The explain-back test asks whether you could explain, in your own words, what a piece of code does and why, to someone who has not seen it. It is Simon Willison's golden rule turned into a check: "I won't commit any code to my repository if I couldn't explain exactly what it does to somebody else." It is also the honest version of the rubber duck: if the explanation stalls at a line, that line is where to read, ask or experiment next.

You do not need to pass it for every line of a throwaway game. But try it on the parts you will change next, and on anything that handles input, stores data or talks to the network. For this game, a good level after step 2 is being able to say, without looking:

  • where the loop is and what it does each frame;
  • why every speed is multiplied by dt;
  • how a key press ends up moving the ship;
  • which constant to change to make the ship slower to follow.

Reading code is also what a code review does in a team, and what the later pages of this topic build on: Debugging with AI goes faster when you know where to look, and From Prototype to Real Software starts with the question of whether anyone can explain the code.