Build 6 · Faster, Harder, a Score
Your first self-written prompt: a game that gets harder the longer you last, combos for quick shooting, a high score that is remembered, one shield, and a console along the bottom.
What you add
So far the game is the same at minute five as at second five. In this step it learns to push back: the longer you last, the more rocks come, faster and more often straight at you. In return it rewards quick shooting, remembers your best score, forgives one mistake and shows all of that in a console along the bottom.
- Levels. A new level after a number of rocks shot, each one harder than the one before, up to a maximum.
- Combos. Hits in quick succession multiply the points.
- A high score that the browser keeps between games, and, where it is allowed, between visits.
- A shield that takes the first hit, followed by a short safe time.
- A console with RESTART, the level, the number of rocks shot, the score, the high score and the shield.
The real change is in how you build it: this is the first step where you write the prompt. The page right before this one, Writing Your Own Prompt, explains the template, the constraints and the acceptance check that you use here.
Before you start
Save a known-good copy of step 5 first. A free CodePen account has no version history to roll back to (as of October 2026), so fork the Pen (for example as "Build 5 – working") or export it as a .zip, as described on Saving Versions and Going Back. Check that the copy still shoots, scores and restarts.
Then open a text file for your prompt. Writing it outside the chat lets you go through the checklist calmly, and you keep it for the next step: a self-written prompt that worked is the start of your own template.
Your prompt
Fill in every bracket in the template below. The text outside the brackets is the part that stays the same for every step: the context, "keep everything that already works", whole files back, and the request for testable rules. If you already wrote a project brief, you can put it in place of the first sentence.
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 6: [ONE SENTENCE: WHAT THIS STEP ADDS]
Difficulty:
- A new level every [NUMBER] rocks shot, up to level [NUMBER].
- Level 1 is: [HOW MANY ROCKS AT ONCE, HOW OFTEN A NEW ONE, HOW FAST,
WHAT SHARE AIMS AT ME]. The last level is: [THE SAME FOUR NUMBERS].
Levels in between: [HOW THEY ARE MIXED, e.g. a straight line].
Score:
- [HOW POINTS WORK, e.g. a combo for quick hits: how quick, how high]
- A highscore that is saved in localStorage when the browser allows it.
Reading and writing storage must be wrapped in try/catch: in some
frames it throws, and then the game must simply keep the highscore in
memory.
Forgiveness:
- [WHAT SAVES ME ONCE, e.g. a shield bubble, extra lives] and what it looks
like, and what happens right after a hit.
Screen:
- [WHAT THE CONSOLE OR HUD SHOWS, AND WHERE]
- [WHAT THE GAME OVER SCREEN SHOWS]
Keep the pure logic ([NAME THE RULES YOU WANT AS SMALL FUNCTIONS]) in small
functions without Three.js or the DOM in the rules section, so I can test
them. Comment for a beginner.
[paste index.html, style.css and script.js here]
There is no right answer to the brackets. The reference version below is one possible answer, with one set of numbers and one idea of fairness. Yours will look different, and that is the point: from this step on, the game becomes yours.
What to mention
Before you send the prompt, check that it says all of this. Each point is a decision; any point you leave out, the AI decides for you.
- What makes a level harder, with numbers for the first and the last level (not "a bit faster").
- When a level goes up: after a number of hits, or after a time.
- How the score works, including any multiplier and its maximum.
-
localStorageinsidetry/catch, with the key name you want, and that the game must work without it. - What protects you once (a shield, lives), how you see that it is gone, and a short safe time after a hit, so one rock does not cost two lives.
- Exactly which numbers appear on screen, and where (a bottom console, the top corners).
- A RESTART button, if you want one, and that it must not steal the Space key after you click it.
- "Keep everything that already works" and "give me all three files complete".
Three hints
-
Describe the curve, not the feeling. "Level 1: 8
rocks at most, one every 0.9 s, speed 20, 15% aimed; level 10: 24,
every 0.25 s, speed 44, 60% aimed" gives you something you can
test and tune. "Make it harder over time" gives you whatever the
AI imagines. Ask for the curve as a function
difficulty(level)that returns those four numbers, and print a few levels in the console to check it (the acceptance check on the previous page does exactly that). -
The site's preview cannot save anything. The game
frame on this site (and some CodePen views) has no storage, so
localStoragethrows aSecurityError. If the high score code is not wrapped intry/catch, the whole game stops. Test it by typinglocalStoragein the console of the preview, and ask the AI what happens when that line throws. -
A button keeps the focus. After you click
RESTART, the button still has the keyboard focus, and pressing
Space "clicks" it again. Ask the AI to take the focus away after
the click (
blur()), or you will restart every time you fire.
What you should see
In the reference version:
- A console along the bottom: RESTART, LEVEL, COUNTER, SCORE, HIGHSCORE and a SHIELD square.
- A cyan bubble around the jet. The first hit pops it: the jet blinks for a moment and the SHIELD square goes empty. The second hit ends the game.
- Every 10 rocks the LEVEL goes up, and the rocks come faster, more often and more often straight at you.
- Quick hits show COMBO x2, x3 … at the top right, and score 10 × the combo.
- GAME OVER shows score, level and high score, and NEW HIGHSCORE! when you beat it.
- RESTART works at any time, and Space after clicking it fires instead of restarting.
Your own version should pass the checks you wrote yourself: the numbers of your curve, your combo rule, your shield.
If it goes wrong
For anything not on this list, use the method from Debugging with AI: the exact error from the console, one problem per prompt, and back to your saved copy when a fix makes things worse.
-
The game stops right away with a
SecurityErrorin the console: storage is used withouttry/catch. Ask the AI to wrap both reading and writing, and to keep the high score in a variable when storage is not available. - The high score is gone after reloading the page in CodePen: that may be normal: the preview's address can change between sessions, and storage belongs to an address. Within one session it should stay.
- Two lives lost from one rock: there is no safe time after a hit, or the rock is not removed when it hits the shield.
-
The game is impossible at level 3: print
difficulty(1)todifficulty(10)in the console and look at the numbers before you change anything. Then ask for a change to the numbers, not for "easier". - Pressing Space restarts the game: the RESTART button still has the focus after the click (hint 3).
The reference version
This is one possible result of step 6: the tested reference version,
built from one filled-in template. Play runs it right here; the code
under it is the three panels to paste into a Pen (the HTML goes
inside <body>, then the CSS and the JavaScript).
The frame on this page is sandboxed, so some things behave
differently from CodePen. It has no storage: the high score is kept
only until you press Stop or Reset, and the game goes on without it,
which is exactly the case the try/catch is
for. It loads nothing from the network (the site supplies the pixel
font itself).
- Click in the game once before you use the keyboard; on this page, Play moves the focus into the game for you.
- The game needs WebGL 2. According to caniuse, about 96.44% of browsers in use support it, but the three.js manual warns that some devices or browsers still do not; without it the frame stays blank.
-
In CodePen 2.0 the bare
import * as THREE from "three"makes CodePen add the latest Three.js version itself, which may be newer than the r186 (three@0.186.1) this version was built against, as of October 2026.
<canvas id="game"></canvas>
<div class="hud">
<span class="title">RETRO JET</span>
<span id="combo"></span>
</div>
<p id="countdown" class="countdown" hidden>3</p>
<div id="overlay" class="overlay" hidden>
<h2 id="overlay-title">GAME OVER</h2>
<p id="overlay-text"></p>
<button id="overlay-button" type="button">PLAY AGAIN</button>
<p class="small">OR PRESS ENTER</p>
</div>
<div class="console">
<button id="restart" type="button">RESTART</button>
<span>LEVEL <b id="level">1</b></span>
<span>COUNTER <b id="kills">0</b></span>
<span>SCORE <b id="score">0</b></span>
<span>HIGHSCORE <b id="highscore">0</b></span>
<span>SHIELD <i id="shield" class="pip on"></i></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%;
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;
}
/* The big 3-2-1 in the middle of the screen. */
.countdown {
position: fixed;
inset: 0;
display: grid;
place-content: center;
margin: 0;
font: 64px/1 var(--font);
color: var(--amber);
text-shadow: 0 0 18px var(--flame);
pointer-events: none;
}
/* The overlay covers the game for GAME OVER (and later PAUSED). */
.overlay {
position: fixed;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 16px;
padding: 16px;
text-align: center;
font: 11px/1.8 var(--font);
letter-spacing: 0.08em;
color: var(--ice);
background: rgb(10 18 32 / 0.72);
}
/* display: grid would beat the hidden attribute, so say it again. */
.overlay[hidden],
.countdown[hidden] {
display: none;
}
.overlay h2 {
margin: 0;
font: 28px/1.2 var(--font);
color: var(--amber);
text-shadow: 0 0 14px var(--flame);
}
.overlay p {
margin: 0;
}
.overlay button {
padding: 10px 18px;
font: 11px/1 var(--font);
letter-spacing: 0.1em;
color: var(--cyan);
background: transparent;
border: 2px solid var(--cyan);
cursor: pointer;
}
.overlay button:hover,
.overlay button:focus-visible {
color: var(--space);
background: var(--cyan);
}
.overlay .small {
font-size: 9px;
color: var(--cyan-deep);
}
.hud #combo {
color: var(--amber);
}
/* The console: one row of game info along the bottom. */
.console {
position: fixed;
inset: auto 0 0 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 22px;
padding: 10px 14px;
font: 9px/1.6 var(--font);
letter-spacing: 0.08em;
color: var(--cyan-deep);
background: rgb(10 18 32 / 0.85);
border-top: 2px solid var(--cyan-deep);
}
.console b {
font-weight: 400;
color: var(--ice);
}
.console button {
padding: 6px 10px;
font: inherit;
color: var(--amber);
background: transparent;
border: 2px solid var(--copper);
cursor: pointer;
}
.console button:hover,
.console button:focus-visible {
color: var(--space);
background: var(--amber);
}
/* The shield pip: an empty square, filled while the shield is up. */
.pip {
display: inline-block;
width: 10px;
height: 10px;
vertical-align: -1px;
border: 2px solid var(--cyan);
}
.pip.on {
background: var(--cyan);
box-shadow: 0 0 8px 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
// 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 SPAWN_Z = -95; // rocks appear this far ahead
const DESPAWN_Z = 12; // and are recycled once they pass the camera
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
// Hit spheres along the ship (x, y, z from the ship's middle, radius r).
// A few spheres follow the jet's shape much better than one big one.
const SHIP_SPHERES = [
{ x: 0, y: 0, z: -1.1, r: 0.3 }, // nose
{ x: 0, y: 0, z: 0, r: 0.45 }, // body
{ x: 0, y: 0, z: 0.9, r: 0.35 }, // tail
{ x: -1.1, y: -0.05, z: 0.2, r: 0.35 }, // left wing
{ x: 1.1, y: -0.05, z: 0.2, r: 0.35 }, // right wing
];
const ROCK_HIT_SHARE = 0.85; // rocks are lumpy: use a bit less than the radius
const COUNTDOWN = 3; // seconds of 3-2-1 before rocks come
// Guns on the wing tips (x, y, z from the ship's middle).
const GUNS = [
{ x: -1.7, y: -0.02, z: -0.15 },
{ x: 1.7, y: -0.02, z: -0.15 },
];
const AIM_DISTANCE = 30; // the shots meet at the reticle, this far ahead
const BURST_SIZE = 3; // shots per burst
const BURST_GAP = 0.08; // seconds between the shots of a burst
const BURST_PAUSE = 0.35; // seconds before the next burst
const FAN = 0.6; // sideways spread of a burst at the reticle
const BULLET_POOL = 24;
const BULLET_SPEED = 90; // units per second
const BULLET_LIFE = 0.8; // seconds
const BULLET_RADIUS = 0.25; // a bit generous, so near misses count
const ASSIST_ANGLE = 0.06; // radians: rocks this close to the line get aimed at
const PARTICLE_POOL = 600; // pixels shared by all explosions
const PARTICLE_LIFE = 0.9; // seconds
const POINTS_PER_ROCK = 10;
const KILLS_PER_LEVEL = 10; // a new level every 10 rocks
const MAX_LEVEL = 10;
// Level 1 is EASY, level 10 is HARD; levels in between are mixed.
const EASY = { maxRocks: 8, spawnEvery: 0.9, speed: 20, aimChance: 0.15 };
const HARD = { maxRocks: 24, spawnEvery: 0.25, speed: 44, aimChance: 0.6 };
const COMBO_WINDOW = 1.5; // seconds: hit again within this for a combo
const MAX_COMBO = 5;
const SAFE_TIME = 1.5; // seconds the ship blinks and cannot be hit
const HIGHSCORE_KEY = "retro-jet-highscore"; // the name it is saved under
// 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;
}
// The shortest distance from point c to the line from a to b.
function segmentDistance(a, b, c) {
const ab = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z };
const lengthSquared = ab.x * ab.x + ab.y * ab.y + ab.z * ab.z;
const along = (c.x - a.x) * ab.x + (c.y - a.y) * ab.y + (c.z - a.z) * ab.z;
// t = 0 at a, 1 at b: the point on the line closest to c.
const t = lengthSquared === 0 ? 0 : clamp(along / lengthSquared, 0, 1);
return Math.hypot(
a.x + ab.x * t - c.x,
a.y + ab.y * t - c.y,
a.z + ab.z * t - c.z,
);
}
// Did a sphere moving from `from` to `to` in this frame touch one of the
// ship's spheres? Checking the whole path (a "swept sphere") means a fast
// rock cannot jump over the ship between two frames.
function sweptHit(from, to, radius, shipAt, spheres) {
return spheres.some((sphere) => {
const center = {
x: shipAt.x + sphere.x,
y: shipAt.y + sphere.y,
z: shipAt.z + sphere.z,
};
return segmentDistance(from, to, center) < radius + sphere.r;
});
}
// What the countdown shows with this many seconds left: "3", "2", "1".
function countdownLabel(secondsLeft) {
return String(Math.max(1, Math.ceil(secondsLeft)));
}
// The burst rhythm, one frame at a time. burst = { left, wait }: shots
// left in this burst and seconds until the next may fire. Returns the
// new burst and whether to fire now.
function burstStep(burst, trigger, dt) {
const wait = Math.max(0, burst.wait - dt);
if (wait > 0 || (burst.left === 0 && !trigger)) {
return { left: burst.left, wait, fire: false };
}
const left = (burst.left === 0 ? BURST_SIZE : burst.left) - 1;
return { left, wait: left > 0 ? BURST_GAP : BURST_PAUSE, fire: true };
}
// Where shot number 0, 1 or 2 of a burst goes: the reticle straight
// ahead, a little to the left, middle or right (a slight fan).
function shotTarget(shipAt, shotIndex) {
return {
x: shipAt.x + (shotIndex - 1) * FAN,
y: shipAt.y,
z: shipAt.z - AIM_DISTANCE,
};
}
// Light aim assist: which rock (index) is closest to the line straight
// ahead, if it is within ASSIST_ANGLE of it? -1 when none is.
function assistTarget(from, rockPositions) {
let best = -1;
let bestAngle = ASSIST_ANGLE;
rockPositions.forEach((rock, i) => {
const ahead = from.z - rock.z;
if (ahead <= 2) return; // behind or right next to us
const angle = Math.atan2(
Math.hypot(rock.x - from.x, rock.y - from.y),
ahead,
);
if (angle < bestAngle) {
best = i;
bestAngle = angle;
}
});
return best;
}
// Aim ahead of a moving rock: where it will be when the bullet arrives.
function leadPoint(rockAt, rockVelocity, from, bulletSpeed) {
const time =
Math.hypot(rockAt.x - from.x, rockAt.y - from.y, rockAt.z - from.z) /
bulletSpeed;
return {
x: rockAt.x + rockVelocity.x * time,
y: rockAt.y + rockVelocity.y * time,
z: rockAt.z + rockVelocity.z * time,
};
}
// An explosion pixel flies away from the middle of what exploded.
function outward(point, center, speed) {
const dx = point.x - center.x;
const dy = point.y - center.y;
const dz = point.z - center.z;
const length = Math.hypot(dx, dy, dz) || 1;
return {
x: (dx / length) * speed,
y: (dy / length) * speed,
z: (dz / length) * speed,
};
}
// How bright a pixel still is: 1 when new, 0 when its life is over.
function fade(life, maxLife) {
return clamp(life / maxLife, 0, 1);
}
function levelFor(kills) {
return Math.min(MAX_LEVEL, 1 + Math.floor(kills / KILLS_PER_LEVEL));
}
// The difficulty curve: a level -> how many rocks, how often, how fast,
// and how many aim at the ship. A straight line from EASY to HARD.
function difficulty(level) {
const t = (clamp(level, 1, MAX_LEVEL) - 1) / (MAX_LEVEL - 1); // 0 to 1
const mix = (easy, hard) => easy + (hard - easy) * t;
return {
maxRocks: Math.round(mix(EASY.maxRocks, HARD.maxRocks)),
spawnEvery: mix(EASY.spawnEvery, HARD.spawnEvery),
speed: mix(EASY.speed, HARD.speed),
aimChance: mix(EASY.aimChance, HARD.aimChance),
};
}
// Quick kills build a combo (x2, x3 ... up to x5); a slow one resets it.
function nextCombo(combo, sinceLastKill) {
return sinceLastKill <= COMBO_WINDOW ? Math.min(MAX_COMBO, combo + 1) : 1;
}
// The highscore saved in the browser, or 0. Reading storage can throw
// (in a sandboxed frame it does), and the saved text can be rubbish.
function loadHighscore(getStorage) {
try {
const saved = Number(getStorage().getItem(HIGHSCORE_KEY));
return Number.isFinite(saved) && saved > 0 ? Math.floor(saved) : 0;
} catch {
return 0; // no storage: the highscore lasts until the page closes
}
}
// Saves the highscore; returns false when the browser would not let us.
function saveHighscore(getStorage, score) {
try {
getStorage().setItem(HIGHSCORE_KEY, String(score));
return true;
} catch {
return false;
}
}
// ---- 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");
// The shield: a see-through cyan bubble around the ship.
const bubble = new THREE.Mesh(
new THREE.SphereGeometry(1, 24, 16),
new THREE.MeshBasicMaterial({
color: PALETTE.cyan,
transparent: true,
opacity: 0.18,
blending: THREE.AdditiveBlending,
depthWrite: false,
}),
);
bubble.name = "shield";
bubble.scale.set(2.1, 1.1, 2.1);
ship.add(bubble);
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);
if (event.code === "Enter" || event.code === "Space") restart();
});
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 { aimChance, speed: levelSpeed } = difficulty(level);
const aimed = Math.random() < aimChance;
const speed = levelSpeed * (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
// ---- Bullets: glowing capsules with a tail, from a pool ----
const glowLook = (color, opacity = 1) =>
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: THREE.AdditiveBlending, // adds light: it glows
depthWrite: false,
fog: false,
});
const bulletShape = new THREE.CapsuleGeometry(0.07, 0.5, 4, 8);
bulletShape.rotateX(Math.PI / 2); // lengthwise along z
const tailShape = new THREE.CapsuleGeometry(0.04, 1.6, 4, 6);
tailShape.rotateX(Math.PI / 2);
tailShape.translate(0, 0, 1.1); // behind the bullet
const bullets = makePool(BULLET_POOL, () => {
const bullet = new THREE.Mesh(bulletShape, glowLook(PALETTE.amber));
bullet.add(new THREE.Mesh(tailShape, glowLook(PALETTE.flame, 0.5)));
bullet.name = "bullet";
return bullet;
});
let burst = { left: 0, wait: 0 };
let clicked = false; // a click asks for one burst
let shotCount = 0; // to alternate between the two guns
// ---- The reticle: a diamond where the shots meet ----
const reticle = new THREE.Mesh(
new THREE.RingGeometry(0.8, 0.95, 4),
new THREE.MeshBasicMaterial({
color: PALETTE.ice,
transparent: true,
depthTest: false,
fog: false,
}),
);
reticle.name = "reticle";
reticle.renderOrder = 1; // drawn last, on top of everything
scene.add(reticle);
let assisted = null; // the rock the aim assist picked, or null
function fireShot() {
const shotIndex = BURST_SIZE - 1 - burst.left; // 0, 1, 2
const gun = GUNS[shotCount++ % GUNS.length];
const from = {
x: ship.position.x + gun.x,
y: ship.position.y + gun.y,
z: ship.position.z + gun.z,
};
const aimAt = assisted
? leadPoint(assisted.mesh.position, assisted.velocity, from, BULLET_SPEED)
: shotTarget(ship.position, shotIndex);
const bullet = freeOne(bullets);
if (!bullet) return;
switchOn(bullet, from, aimVelocity(from, aimAt, BULLET_SPEED));
bullet.life = BULLET_LIFE;
bullet.mesh.lookAt(aimAt.x, aimAt.y, aimAt.z); // point the capsule where it flies
}
// ---- Explosions: pixels from one shared pool (one Points object) ----
// A pixel's color fades to black; with additive blending black adds
// nothing, so a finished pixel is simply invisible.
const particlePositions = new Float32Array(PARTICLE_POOL * 3);
const particleColors = new Float32Array(PARTICLE_POOL * 3);
const particles = Array.from({ length: PARTICLE_POOL }, () => ({ life: 0 }));
const particleGeometry = new THREE.BufferGeometry();
particleGeometry.setAttribute("position", attribute(particlePositions));
particleGeometry.setAttribute("color", attribute(particleColors));
const particleLook = new THREE.PointsMaterial({ size: 4, vertexColors: true });
particleLook.sizeAttenuation = false; // square pixels, whatever the distance
particleLook.blending = THREE.AdditiveBlending;
particleLook.depthWrite = false;
const particleCloud = new THREE.Points(particleGeometry, particleLook);
particleCloud.frustumCulled = false; // the pixels move; always draw them
scene.add(particleCloud);
let nextParticle = 0; // the oldest pixel, reused first
const corner = new THREE.Vector3(); // reused, so we make no garbage
// Pixels start on the surface of the mesh (at its corners) and fly out.
function explode(mesh, count, rgb, drift = { x: 0, y: 0, z: 0 }) {
mesh.updateMatrixWorld();
const surface = mesh.geometry.attributes.position;
const center = mesh.getWorldPosition(new THREE.Vector3());
for (let i = 0; i < count; i++) {
corner.fromBufferAttribute(
surface,
Math.floor(Math.random() * surface.count),
);
corner.applyMatrix4(mesh.matrixWorld); // from the mesh's own space to the world
const particle = particles[nextParticle];
const away = outward(corner, center, 3 + Math.random() * 8);
particle.velocity = {
x: away.x + drift.x * 0.3,
y: away.y + drift.y * 0.3,
z: away.z + drift.z * 0.3,
};
particle.life = PARTICLE_LIFE * (0.6 + Math.random() * 0.4);
particle.maxLife = particle.life; // to know how far it has faded
particle.rgb = Math.random() < 0.5 ? rgb : hexToRgb(PALETTE.amber);
particlePositions.set([corner.x, corner.y, corner.z], nextParticle * 3);
nextParticle = (nextParticle + 1) % PARTICLE_POOL;
}
}
// ---- Game state: "countdown", "playing" or "over" ----
let state = "countdown";
let countdownLeft = COUNTDOWN; // seconds
let survived = 0; // seconds
let score = 0;
let kills = 0;
let level = 1;
let combo = 1;
let lastKill = -Infinity; // game time of the last kill
let shield = true;
let safeTime = 0; // seconds of safety left after the shield took a hit
const getStorage = () => window.localStorage;
let highscore = loadHighscore(getStorage);
// ---- HUD and overlay: plain HTML on top of the canvas ----
// textContent (not innerHTML) puts text in, never markup.
const $ = (selector) => document.querySelector(selector);
const scoreText = $("#score");
const countdownText = $("#countdown");
const overlay = $("#overlay");
function showOverlay(title, text, buttonLabel) {
$("#overlay-title").textContent = title;
$("#overlay-text").textContent = text;
$("#overlay-button").textContent = buttonLabel;
overlay.hidden = false;
}
function gameOver() {
state = "over";
explode(ship.children[0], 160, hexToRgb(PALETTE.hull)); // the hull bursts
ship.visible = false;
let text = `SCORE ${score} · LEVEL ${level} · HIGHSCORE ${Math.max(score, highscore)}`;
if (score > highscore) {
highscore = score;
saveHighscore(getStorage, highscore); // fine if it fails: we keep it here
text += " · NEW HIGHSCORE!";
}
showOverlay("GAME OVER", text, "PLAY AGAIN");
}
// A rock hit the ship: the shield takes the first hit, the second ends it.
function shipHit(rock) {
if (!shield) return gameOver();
shield = false;
safeTime = SAFE_TIME;
switchOff(rock);
explode(rock.mesh, 40, hexToRgb(PALETTE.cyan), rock.velocity);
}
// A bullet broke a rock: count it, score it (with the combo), level up.
function rockShot(rock) {
switchOff(rock);
explode(rock.mesh, 40, hexToRgb(SLATE[2]), rock.velocity);
combo = nextCombo(combo, survived - lastKill);
lastKill = survived;
score += POINTS_PER_ROCK * combo;
kills += 1;
level = levelFor(kills);
}
// Back to the start: clear the rocks, ship at its start, count down.
// The console's RESTART button may do this at any time (force).
function restart(force = false) {
if (state !== "over" && !force) return;
rocks.forEach(switchOff);
bullets.forEach(switchOff);
burst = { left: 0, wait: 0 };
score = 0;
kills = 0;
level = 1;
combo = 1;
lastKill = -Infinity;
shield = true;
safeTime = 0;
target = startTarget(bounds);
ship.position.set(target.x, target.y, 0);
ship.visible = true;
survived = 0;
spawnTimer = 0;
countdownLeft = COUNTDOWN;
state = "countdown";
overlay.hidden = true;
}
$("#overlay-button").addEventListener("click", () => restart());
$("#restart").addEventListener("click", (event) => {
event.currentTarget.blur(); // or Space (fire!) would press it again
restart(true);
});
canvas.addEventListener("pointerdown", () => (clicked = true)); // click = fire
// Did a rock hit the ship this frame? `from` is where it was before.
function rockHitsShip(rock, from) {
const radius = rock.radius * ROCK_HIT_SHARE;
return sweptHit(
from,
rock.mesh.position,
radius,
ship.position,
SHIP_SPHERES,
);
}
// ---- 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;
const launching = state === "playing"; // no new rocks during 3-2-1
const { maxRocks, spawnEvery } = difficulty(level);
if (launching && spawnTimer <= 0 && activeIn(rocks).length < maxRocks) {
spawnTimer = spawnEvery;
launchRock(freeOne(rocks));
}
for (const rock of activeIn(rocks)) {
const from = rock.mesh.position.clone();
drift(rock, dt);
if (state === "playing" && safeTime === 0 && rockHitsShip(rock, from)) {
shipHit(rock);
}
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);
}
}
function updateGuns(dt) {
// Which rock is in line? The reticle lights up cyan when there is one.
const ahead = activeIn(rocks);
const pick = assistTarget(
ship.position,
ahead.map((r) => r.mesh.position),
);
assisted = pick >= 0 ? ahead[pick] : null;
reticle.position.set(
ship.position.x,
ship.position.y,
ship.position.z - AIM_DISTANCE,
);
reticle.material.color.setHex(assisted ? PALETTE.cyan : PALETTE.ice);
reticle.material.opacity = assisted ? 1 : 0.45;
// Space held (or a click) fires bursts of three.
const trigger = state === "playing" && (held.has("Space") || clicked);
burst = burstStep(burst, trigger, dt);
if (burst.fire) {
fireShot();
clicked = false;
}
for (const bullet of activeIn(bullets)) {
const from = bullet.mesh.position.clone();
drift(bullet, dt);
bullet.life -= dt;
if (bullet.life <= 0) switchOff(bullet);
// Check the bullet's whole path this frame, like the rocks.
const hit = activeIn(rocks).find(
(rock) =>
segmentDistance(from, bullet.mesh.position, rock.mesh.position) <
rock.radius + BULLET_RADIUS,
);
if (hit && bullet.active) {
switchOff(bullet);
rockShot(hit);
}
}
}
function updateParticles(dt) {
particles.forEach((particle, i) => {
if (particle.life <= 0) return;
particle.life -= dt;
for (const [k, axis] of ["x", "y", "z"].entries()) {
particlePositions[i * 3 + k] += particle.velocity[axis] * dt;
}
const glow = fade(particle.life, particle.maxLife);
particleColors.set(
particle.rgb.map((c) => c * glow),
i * 3,
);
});
// Tell Three.js the numbers changed.
particleGeometry.attributes.position.needsUpdate = true;
particleGeometry.attributes.color.needsUpdate = true;
}
// After a hit on the shield the ship blinks while it is safe.
function updateShield(dt) {
safeTime = Math.max(0, safeTime - dt);
if (state !== "over") ship.visible = Math.floor(safeTime * 10) % 2 === 0;
bubble.visible = shield;
}
// The console along the bottom, and the combo text at the top.
function updateConsole() {
if (survived - lastKill > COMBO_WINDOW) combo = 1; // too slow: no combo
scoreText.textContent = score;
$("#level").textContent = level;
$("#kills").textContent = kills;
$("#highscore").textContent = Math.max(score, highscore);
$("#shield").classList.toggle("on", shield);
$("#combo").textContent = combo > 1 ? `COMBO x${combo}` : "";
}
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);
if (state === "countdown") {
countdownLeft -= dt;
if (countdownLeft <= 0) state = "playing";
}
if (state === "playing") survived += dt;
if (state !== "over") updateShip(dt);
updateRocks(dt); // rocks keep drifting behind the GAME OVER screen
updateGuns(dt);
updateParticles(dt);
updateShield(dt);
updateConsole();
countdownText.hidden = state !== "countdown";
countdownText.textContent = countdownLabel(countdownLeft);
renderer.render(scene, camera);
});
How it works
The excerpts below are copied from the reference
script.js. Your version will use other names and
numbers; look for the same ideas in it, or ask the AI to show you
where they are.
The difficulty curve
A difficulty curve is the rule that says how hard a game is at each point: here, how many rocks may fly at once, how often a new one comes, how fast they are and how many aim at you, for each level. The reference keeps the two ends of the curve as plain numbers at the top of the script:
const KILLS_PER_LEVEL = 10; // a new level every 10 rocks
const MAX_LEVEL = 10;
// Level 1 is EASY, level 10 is HARD; levels in between are mixed.
const EASY = { maxRocks: 8, spawnEvery: 0.9, speed: 20, aimChance: 0.15 };
const HARD = { maxRocks: 24, spawnEvery: 0.25, speed: 44, aimChance: 0.6 };
Everything in between is calculated. The
mix function inside difficulty() is a
linear interpolation: it
finds a value between two known values, at a fraction
t of the way from the first to the second, along a
straight line. At level 1, t is 0 and you get EASY; at
level 10 it is 1 and you get HARD; at level 4 it is a third of the
way.
// The difficulty curve: a level -> how many rocks, how often, how fast,
// and how many aim at the ship. A straight line from EASY to HARD.
function difficulty(level) {
const t = (clamp(level, 1, MAX_LEVEL) - 1) / (MAX_LEVEL - 1); // 0 to 1
const mix = (easy, hard) => easy + (hard - easy) * t;
return {
maxRocks: Math.round(mix(EASY.maxRocks, HARD.maxRocks)),
spawnEvery: mix(EASY.spawnEvery, HARD.spawnEvery),
speed: mix(EASY.speed, HARD.speed),
aimChance: mix(EASY.aimChance, HARD.aimChance),
};
}
The clamp keeps the level between 1 and 10, so a bug
elsewhere cannot produce level 37 with rocks faster than bullets.
The table that difficulty() produces for a few levels
is printed on
Writing Your Own Prompt.
The spawn rate is how often new objects enter a game, usually given as the time between two of them: here, one new rock every 0.9 seconds at level 1 (about 1.1 per second) and every 0.25 seconds at level 10 (4 per second). The spawn code from step 3 now asks the curve for both limits each time:
const { maxRocks, spawnEvery } = difficulty(level);
if (launching && spawnTimer <= 0 && activeIn(rocks).length < maxRocks) {
spawnTimer = spawnEvery;
launchRock(freeOne(rocks));
}
A straight line is the simplest curve, not the only one. Many games rise quickly at first and flatten later, or add a calm level after a hard one. With the curve in one function, trying another shape is one prompt that changes one function.
Levels and combos
The level follows from the number of rocks shot: one level per 10, never above 10. The combo follows from the time since the last kill:
// Quick kills build a combo (x2, x3 ... up to x5); a slow one resets it.
function nextCombo(combo, sinceLastKill) {
return sinceLastKill <= COMBO_WINDOW ? Math.min(MAX_COMBO, combo + 1) : 1;
}
A kill within 1.5 seconds of the previous one raises the combo by one, up to 5; a slower one sets it back to 1. Each rock then scores 10 times the combo. Both are pure functions of plain numbers, so the tests of the reference version can check them without a browser.
The high score
A high score is the best score
reached so far, kept between games, and in a browser game usually
also between visits, by storing it in the browser. The reference
uses
web storage: localStorage, which keeps data across browser
sessions, per origin. Two things can go wrong, and the code handles
both:
// The highscore saved in the browser, or 0. Reading storage can throw
// (in a sandboxed frame it does), and the saved text can be rubbish.
function loadHighscore(getStorage) {
try {
const saved = Number(getStorage().getItem(HIGHSCORE_KEY));
return Number.isFinite(saved) && saved > 0 ? Math.floor(saved) : 0;
} catch {
return 0; // no storage: the highscore lasts until the page closes
}
}
// Saves the highscore; returns false when the browser would not let us.
function saveHighscore(getStorage, score) {
try {
getStorage().setItem(HIGHSCORE_KEY, String(score));
return true;
} catch {
return false;
}
}
-
Storage can refuse. In a sandboxed frame like the
one on this page, even reading
localStoragethrows aSecurityError. That is why the code does not touchwindow.localStoragedirectly but receives a small function that returns it, and only calls that function insidetry. Thecatchturns "no storage" into "no saved high score" (see try, catch and finally). -
The stored text can be anything. Storage holds
strings, and the browser lets anyone with the console edit them.
Number()turns the text into a number (a missing item givesnull, which becomes 0), and only a finite number above 0 is accepted. Store only a number, and check it when you read it back.
Storage belongs to an origin (see the same-origin policy). CodePen runs your preview on its own domains, and in CodePen 2.0 the preview address contains a session identifier that is only valid for a limited time, according to CodePen's documentation (as of October 2026). Whether a high score survives from one day to the next in the editor preview has not been checked; it may be lost, and for a game that is fine.
The shield and the safe time
The shield is a see-through cyan sphere attached to the ship. The first hit removes the shield and the rock, and starts 1.5 seconds of safe time in which the ship blinks and cannot be hit; only a hit without a shield ends the game:
// A rock hit the ship: the shield takes the first hit, the second ends it.
function shipHit(rock) {
if (!shield) return gameOver();
shield = false;
safeTime = SAFE_TIME;
switchOff(rock);
explode(rock.mesh, 40, hexToRgb(PALETTE.cyan), rock.velocity);
}
Removing the rock and the safe time solve the same problem from two sides: the swept test from step 4 runs every frame, so a rock that stays in contact would otherwise hit again in the very next frame and end the game at once.
The RESTART button and the focus
A clicked button keeps the keyboard focus, and Space on a focused button presses it. In a game where Space fires, that would restart the game on the first shot. The reference gives the focus up straight after the click:
$("#restart").addEventListener("click", (event) => {
event.currentTarget.blur(); // or Space (fire!) would press it again
restart(true);
});
The true is the "force" argument: the console's button
may restart at any moment, while Enter and Space only restart on the
GAME OVER screen.