What you add

Over eight build steps you make one small 3D space game, "RETRO JET", by describing it to an AI assistant and pasting what you get into CodePen. This first step builds the stage: a full-window canvas, a camera behind and slightly above the flight path, about 1,800 stars that stream toward you and wrap around forever, and a small title display. It is deliberately boring. If something is wrong with the setup, you want to find out now, with about 110 lines of code, not later with several hundred.

The game uses Three.js, an open-source JavaScript library (MIT license) for 3D graphics in the browser. It hides most of the low-level drawing work behind objects such as scenes, cameras, shapes, materials and lights. Underneath it draws with WebGL, the browser's JavaScript interface for drawing 2D and 3D graphics on the graphics card (the GPU) inside a <canvas> element. Three.js's manual calls its WebGLRenderer "the recommended choice for pure WebGL 2 applications", and that is the renderer the game uses.

  • WebGL 2 is required. As of October 2026, caniuse puts browser support for WebGL 2 at 96.44%, and the Three.js manual warns that "some devices or browsers may still not support WebGL 2". Without it the game cannot draw anything.
  • Nothing to install. CodePen runs in the browser, and the AI assistant is a separate chat in another tab; see Vibe Coding Tools for the kinds of tools and How an AI Model Writes Code for what the assistant does and does not know about your Pen.

Before you start

A Pen is one project on CodePen: an editor and a live preview in one page. Since CodePen 2.0 (launched on 23 July 2026), every Pen has its own small file system; a new Pen starts with three files, index.html, style.css and script.js. A free account allows three files per Pen, which is exactly what this game needs (as of October 2026, CodePen's docs say configuration files such as package.json do not count toward that limit). Public Pens are visible to everyone and MIT licensed, so never put anything private in one.

  1. Log in to CodePen and create a new Pen. Check that you see the three files.
  2. In CodePen 2.0, index.html is a complete HTML page with the tags that load the other two files already in it. Find the script tag and make sure it reads:
    <script type="module" src="script.js"></script>
    type="module" is what allows import in script.js (see ES modules). The Classic editor adds it by itself once it sees an import; CodePen 2.0's documentation does not say whether its default tag has it, so check.
  3. The AI gives you only the HTML that belongs inside <body>. Paste it there, between <body> and the script tag, and keep the script tag. The CSS goes into style.css, the JavaScript into script.js.
  4. Save the Pen once. CodePen's autosave (every 20 seconds) only starts after the first save.

How Three.js gets into the Pen

The first line of script.js will be import * as THREE from "three";. The word "three" is a package name, not an address, so the browser needs to be told where to fetch it. That is the job of an import map: a <script type="importmap"> element containing JSON that maps names used in import statements to URLs. It has to be processed before the first module that uses those names.

In CodePen 2.0 you do not write it yourself. When CodePen sees the bare name "three", it creates a package.json with the latest version of Three.js and injects an import map that points to the esm.sh CDN. That version may be newer than the one this game was built and tested with: the reference version below was built against Three.js r186 (three@0.186.1, the latest release as of October 2026). If a newer release ever breaks something, you can pin the version by changing the first line of script.js to a full URL:

import * as THREE from "https://esm.sh/three@0.186.1";

Alternatively, edit the version in the generated package.json to 0.186.1.

If you use the Classic editor

CodePen's older editor still works at codepen.io/pen. It has three panels instead of files, and its HTML panel holds only the contents of <body>. It does not add the package for you, so a bare "three" has nowhere to go. Replace the first line of the JavaScript panel with a full-URL import of the same version:

import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js";

That is all: nothing goes into the HTML panel, and the rest of the code stays the same. The file imports the other half of Three.js, three.core.js, from the same folder on the same CDN by itself. Every step gives you a complete new script.js, so change the first line again after each paste. The Three.js manual's rule applies here too: import everything from the same version of Three.js and from the same CDN.

The prompt

Open a new chat in the AI assistant of your choice and paste the prompt below as it is. It describes the scene, but also how the code should be written: which Three.js parts to use and which to avoid, delta time, and the pure logic in small functions at the top. Why a prompt spells all that out is explained on Prompting in Small Steps.

I am building a small 3D space game in one CodePen 2.0 Pen, step by step.
The Pen has three files: index.html, style.css and script.js.
Use Three.js through `import * as THREE from "three";` at the top of
script.js (CodePen adds the package itself). Do not use any other library.

Step 1: just the scene, no game yet.

- A full-window canvas with the id "game". The page must not scroll.
- A dark navy-black background (#0a1220).
- A camera behind and a little above the middle, looking ahead into the
  scene: position (0, 1.4, 8), looking at (0, 0.6, -12). Field of view 60.
- About 1800 stars as one THREE.Points object. Put them in a long tube
  around the flight path (not on the path itself), from far ahead to just
  behind the camera. Every star moves toward the camera; when it passes the
  camera it jumps back to the far end, so the stars stream past forever.
  Stars are 2 pixels (sizeAttenuation false), in slightly different
  icy-white brightnesses.
- A small HUD in a monospace "pixel" font: the title RETRO JET at the top
  left and "STEP 1 · STARFIELD" at the top right. Use
  font-family: "Press Start 2P", "Courier New", monospace.
  Put the colors in CSS custom properties.
- Handle window resizing (renderer size, camera aspect,
  updateProjectionMatrix).

How to write the code:
- Use WebGLRenderer and renderer.setAnimationLoop.
- Measure time with THREE.Timer (not THREE.Clock, which is deprecated):
  timer.connect(document), then timer.update(time) and timer.getDelta()
  every frame. Move everything by speed x delta time, and clamp the delta
  to at most 0.05 seconds.
- Put the pure logic (where a star starts, how it moves and wraps around)
  in small functions without Three.js or the DOM, at the top of
  script.js, so I can test them later.
- Comment the code for a beginner.

Give me the complete contents of all three files: for index.html only the
part inside <body>, without script tags.

Optional: for the real pixel font in CodePen, put this line at the very top of style.css. Without it the text falls back to Courier New, which also looks fine.

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

Paste the three parts of the answer into the three files. Your assistant's code will differ from the reference version below in names, comments and details. That is expected: it only has to pass the checks in the next section.

What you should see

  • A dark navy screen filled with small white dots.
  • The dots move toward you, faster as they come closer, and never run out.
  • The middle of the screen stays fairly empty (the stars are around the flight path, not on it).
  • RETRO JET at the top left, STEP 1 · STARFIELD at the top right.
  • Make the preview narrower and wider: the picture is never stretched and nothing scrolls.

If it goes wrong

  • A white or empty preview, nothing visible in the console: open CodePen's own console. In CodePen 2.0 it is a separate panel that is "always in the correct context". The browser's developer tools start in the context of the codepen.io page itself, so switch them to the preview frame first (see browser DevTools).
  • "Cannot use import statement outside a module" or "THREE is not defined": the script tag is missing type="module", or the line import * as THREE from "three"; is missing or changed.
  • A warning that Clock is deprecated: the code uses THREE.Clock. Ask the AI to switch to THREE.Timer with connect(document).
  • The stars stop after a few seconds or bunch up: the wrap-around is missing or wrong. Ask: "Explain how a star gets back to the far end, and show me the function."
  • Stars are huge squares or invisible: check size and sizeAttenuation: false on the PointsMaterial.
  • The picture is stretched after a resize: the resize handler must also set camera.aspect and call camera.updateProjectionMatrix().
  • Still black, and the console mentions WebGL: the browser or device cannot run WebGL 2, or graphics acceleration is switched off in the browser settings. Try another browser.

The reference version

Below is one possible result of this step: the tested reference version. Press Play to run it right here. 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 1 · STARFIELD</span>
</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%;
}

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

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

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

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

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

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

How it works

You do not need to understand every line to go on, but the four ideas below come back in every step. The excerpts are copied from the reference script.js.

Renderer, scene and camera

Every Three.js program has the same three parts. The scene holds everything that exists in the 3D world. The camera decides what part of it you see. The renderer draws the scene, as seen by the camera, onto the canvas.

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

A PerspectiveCamera takes a field of view in degrees (top to bottom), an aspect ratio (width divided by height; set properly in the resize function), and a near and a far distance: anything closer than 0.1 or farther than 400 units is not drawn. Negative z is "ahead" in this game, so the camera at z = 8 looks toward z = −12. The resize function updates the aspect ratio and calls camera.updateProjectionMatrix(); forgetting that call is what stretches the picture.

The starfield

The 1,800 stars are not 1,800 objects. They are one THREE.Points object whose geometry holds all positions in a single Float32Array: x, y, z, x, y, z, and so on. Each frame the loop changes the z values in that array and then tells Three.js to send the numbers to the GPU again:

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

One object for all stars is far cheaper to draw than many small ones. The stars appear to speed up as they come closer only because of perspective: they all move at the same 30 units per second.

The loop and the timer

renderer.setAnimationLoop() calls a function once for every frame the browser draws; the Three.js source advises always defining the animation loop with this method rather than with requestAnimationFrame() directly. THREE.Timer measures the time since the previous frame:

const timer = new THREE.Timer();
timer.connect(document);

// … (updateStars, shown above) …

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

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

connect(document) makes the timer skip the time a background tab was hidden, so the stars do not jump when you come back. Moving everything by "speed times the seconds since the last frame" is called delta time; why it matters is shown in step 2, A Ship You Can Fly.

The rules at the top

The prompt asked for "the pure logic … in small functions without Three.js or the DOM, at the top of script.js". In the reference version that is the section marked GAME RULES: the constants (camera position, star count, speeds) and three small functions. randomStar() picks a place for a star in a tube around the flight path, streamStar() moves it forward and wraps it back to the far end, and clampStep() keeps the time step between 0 and 0.05 seconds. Because these functions only do arithmetic, you can run and check them anywhere, even outside the browser; the next page does exactly that.