Build 1 · Your Starfield
The first step of the space game: a new CodePen Pen with Three.js, a camera, and stars that stream toward you forever — proof that the setup works before anything else is built.
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.
- Log in to CodePen and create a new Pen. Check that you see the three files.
-
In CodePen 2.0,
index.htmlis 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 allowsimportinscript.js(see ES modules). The Classic editor adds it by itself once it sees animport; CodePen 2.0's documentation does not say whether its default tag has it, so check. -
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 intostyle.css, the JavaScript intoscript.js. - 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 lineimport * as THREE from "three";is missing or changed. -
A warning that Clock is deprecated: the code uses
THREE.Clock. Ask the AI to switch toTHREE.Timerwithconnect(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
sizeandsizeAttenuation: falseon thePointsMaterial. -
The picture is stretched after a resize: the
resize handler must also set
camera.aspectand callcamera.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.