Build 3 · Rocks from Every Side
Rocks that come out of the fog far ahead, spread over the whole view and fly past the ship, some aimed straight at it — spawning, moving and recycling many objects without slowing the game down.
What you add
Until now the ship has flown through empty space. In this step something comes at it: gray, lumpy rocks that appear far ahead, grow as they approach and fly past. Some aim at the ship, so you already have to dodge, even though a hit does nothing yet; collisions come in step 4. The step is only about getting many objects to appear, move and disappear correctly, which is a problem of its own.
- Fog in the background color, so far rocks are hidden.
- Sixteen rock shapes made once: lumpy, faceted, in slate-gray shades.
- A pool of about 30 rock meshes, reused for the whole game.
- A new rock every 0.7 seconds, at most 14 in flight, 30% of them aimed at the ship.
The prompt is longer than the first two, because it says how to build the rocks, not only what they should look like. That is the lesson of Reading the Code You Got put into practice: when you know what to look for in the code (an object created every frame, a list that only grows), you can ask for the right structure up front instead of finding the problem later.
Before you start
Save the Pen and copy your current script.js into a
text file (or fork the Pen), so you can go back if this step goes
wrong.
Saving Versions and Going Back
explains the options on a free account. Then copy all three files of
your Pen; they go at the end of the prompt.
Your step 2 does not have to match the reference version. The prompt
describes the change in words, not by line numbers, so it works on
top of your own ship. If you added the optional pixel-font line from
step 1 at the top of style.css, it stays where it is:
@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap");
Without that line the HUD falls back to Courier New, which works just as well.
The prompt
Copy the whole prompt and replace the last line with the contents of your three files. A new chat works well here, because the prompt carries the whole game with it.
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 3: rocks.
- Add fog in the background color (#0a1220), from 40 to 110 units, so rocks
appear out of the dark. The stars must not be affected by the fog.
- Make 16 rock shapes once: an IcosahedronGeometry with each corner pushed
in or out a little (corners at the same spot must move together, so
there are no cracks), and one slate-gray shade per triangle as vertex
colors. Use one shared MeshStandardMaterial with flatShading and
vertexColors.
- Make a pool of about 30 rock meshes once, sharing these shapes. Do not
create or delete meshes during the game: switch a rock on when it is
launched and off when it is done.
- Launch a new rock every 0.7 seconds (at most 14 in flight), 95 units
ahead, at a random spot spread over the view. About 30% fly straight at
the ship's current position; the others fly toward a random point near
the area where the ship can fly. Random size and slow random spin.
- A rock that has passed the camera goes back to the pool.
- Change the HUD text to "STEP 3 · ROCKS".
Keep the pure logic (the start point and direction of a rock, the
roughening of the shape, the colors per triangle) in small functions
without Three.js or the DOM, in the rules section at the top, and let
random() be passed in so I can test them with fixed numbers.
Comment for a beginner.
[paste index.html, style.css and script.js here]
Three sentences do most of the work. "Do not create or delete meshes
during the game" rules out the most common way generated game code
gets slow. "Corners at the same spot must move together" prevents
cracks in the rocks. And "let random() be passed in" keeps the rock
logic testable: with a fixed sequence of numbers instead of
Math.random, the same input always gives the same rock.
What you should see
- Gray, lumpy, faceted rocks appear far ahead, faint at first, and grow as they come closer.
- They come from all over the view, not only the middle, and they turn slowly.
- Some of them come straight at the jet (they pass right through it for now).
- Rocks keep coming for minutes: none get stuck, and they do not pile up.
- The stars are still bright (not swallowed by the fog).
If it goes wrong
- Rocks pop up close by instead of far away. The start z must be about −95 (ahead is negative z). Ask the AI to show you where the start point is computed.
- The rocks have black cracks or holes. The corners were moved one by one. Corners that share a position must get the same push; ask the AI to change exactly that.
-
After 10 to 20 seconds no more rocks come. Rocks
are never switched off, so the limit of 14 rocks in flight
(
ROCK_MAX) is soon reached. A rock must go back to the pool once its z is past the camera. - The game gets slower over time. The code creates new meshes or geometries for every rock. Ask: "Do you create anything new while the game runs? Reuse the pool instead."
-
The stars disappeared. PointsMaterial uses fog by
default; the stars need
fog: false. -
Everything is one flat gray. The material needs
vertexColors: true, and the geometry needs a color attribute.
The reference version
This is one possible result of step 3: the tested reference version.
Play runs it right here. The code below it is the three panels to
paste into a Pen: the HTML goes inside
<body>, then the CSS and the JavaScript. This
frame is sandboxed, so a few things differ from CodePen: it has no
network, so it supplies the pixel font itself instead of loading it;
it has no storage; and fullscreen is hidden.
Click in the game once before you use the keys; the same goes for
the CodePen preview. The game needs WebGL 2 (about 96% of browsers
in use support it, according to caniuse); without it the frame stays
blank. With the bare "three" import, CodePen 2.0 adds
the latest Three.js version itself, which may be newer than the one
this reference was built against: r186 (three@0.186.1),
as of October 2026.
<canvas id="game"></canvas>
<div class="hud">
<span class="title">RETRO JET</span>
<span>STEP 3 · ROCKS</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)
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 ROCK_MAX = 14; // rocks in flight at most
const SPAWN_Z = -95; // rocks appear this far ahead
const DESPAWN_Z = 12; // and are recycled once they pass the camera
const SPAWN_EVERY = 0.7; // seconds between new rocks
const ROCK_SPEED = 24; // units per second
const AIM_CHANCE = 0.3; // share of rocks aimed straight at the ship
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
// 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;
}
// ---- 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);
// ---- 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());
// ---- 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";
return rock;
});
function launchRock(rock) {
const aimed = Math.random() < AIM_CHANCE;
const speed = ROCK_SPEED * (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
// ---- 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;
}
function updateRocks(dt) {
spawnTimer -= dt;
if (spawnTimer <= 0 && activeIn(rocks).length < ROCK_MAX) {
spawnTimer = SPAWN_EVERY;
launchRock(freeOne(rocks));
}
for (const rock of activeIn(rocks)) {
drift(rock, dt);
rock.mesh.rotation.x += rock.spin.x * dt;
rock.mesh.rotation.y += rock.spin.y * dt;
// Past the camera? Back to the pool.
if (rock.mesh.position.z > DESPAWN_Z) switchOff(rock);
}
}
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);
updateRocks(dt);
renderer.render(scene, camera);
});
How it works
The excerpts below are copied from the reference
script.js. Your version will use other names and other
numbers; look for the same ideas.
Fog
Fog blends everything toward one color with distance. Because the fog color is the background color, a rock 100 units away is almost invisible and becomes clearer as it approaches, instead of popping into view. Many stars are farther away than the fog's far end (110 units) and would vanish, so the stars switch fog off:
scene.fog = new THREE.Fog(SPACE_COLOR, FOG_NEAR, FOG_FAR);
starMaterial.fog = false; // stars are far beyond the fog, but should show
Low-poly rocks
A low-poly model is a 3D shape
built from deliberately few flat triangles (polygons), so its facets
show. It is cheap for the graphics card to draw and gives a clear,
retro look. Each rock starts as an
IcosahedronGeometry(1, 1), a ball of 80 triangles.
Pushing every corner in or out a little makes it lumpy, and
flatShading gives every triangle one flat shade.
An IcosahedronGeometry stores each triangle's corners
separately, so one point on the surface appears several times. If
each copy moved by a different amount, the triangles would separate
and show cracks. The rule keeps a Map from position to
push, so copies of the same point move together:
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;
}
The sixteen shapes and the one material are made once, at the start, and shared by every rock.
The object pool
An object pool is a fixed set of
objects created once and reused: an object is taken from the pool
when it is needed and returned when it is done, instead of being
created and thrown away each time. In a game that launches a rock
every 0.7 seconds for minutes on end, creating a mesh per rock means
memory to allocate, objects to clean up and, in Three.js, geometries
that are not released until someone calls
dispose() (see
Reading the Code You Got). A pool avoids all of it:
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);
Every frame, each active rock moves by its velocity times delta
time, and a rock that has passed the camera is switched off:
invisible and free for the next launch. The limit of 14 rocks in
flight, well below the 30 in the pool, guarantees that
freeOne always finds one, even if you later make rocks
come much faster; what happens when a faster spawn rate meets a
missing limit is a worked example on
Debugging with AI.
Spawning
Spawning is bringing a new object into a running game: choosing where it starts, how it moves and when the next one comes. Here a timer counts down from 0.7 seconds; when it reaches zero and fewer than 14 rocks are in flight, a free rock from the pool gets a start point and a velocity:
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) };
}
The start is a random spot on a wide rectangle 95 units ahead; the
goal is either the ship's current position (an aimed rock) or a
random point near the area the ship can fly in.
aimVelocity turns "from here to there" into a direction
of length 1 times a speed, the vector arithmetic from
length and distance. An AI may instead place rocks on a circle around the flight path
with Math.cos and Math.sin of a random
angle, as the stars in step 1 do; both work (see
the unit circle).
Passing random in, instead of calling
Math.random() inside the function, is what makes the
rule testable: a test can hand it fixed numbers and check that an
aimed rock really ends up at the ship. That is how the reference
version's rules were tested.