Build 7 · Polish: Sound and Explosions
Making the game feel alive with your own prompts: sound with the Web Audio API after the first key press, light flashes, a flame trail, warnings, pop-up points and a pause — and respecting reduced motion.
What you add
After step 6 the game is complete: you fly, shoot, score, level up and lose. Step 7 adds what game makers call polish: the small reactions that make every action feel like it had an effect. The rules stay the same. You choose what to add, and you write the prompt yourself with the template method from Writing Your Own Prompt, as in step 6. Ideas, all of them in the reference version:
- Sound, made in the browser with no sound files: a "pew" per shot, a muffled boom per rock, a rising tone on level up, and an engine hum that rises when you move.
- Light: an amber flash at each explosion, a soft glow at the engine and a red warning glow on rocks that are still far away.
- Pixels: a short flame trail behind the jet, taken from the same particle pool as the explosions.
- Feedback in text: "+10" (or more with a combo) floating up from each broken rock.
- Control: a pause on P or Escape that also starts by itself when you click outside the game or switch tabs, a mute key (M) and a fullscreen button where the browser allows it.
You do not need all of this. Pick two or three things you would miss most, and add them one or two at a time, as on Prompting in Small Steps.
Before you start
Save a known-good version of step 6 first: fork the Pen and give the fork a name such as "Retro Jet · step 6 works", or export it as a .zip file. On a free CodePen account you cannot roll back to an earlier save, so this copy is your way back (see Saving Versions and Going Back).
This matters more than in earlier steps. Polish does not add one new
block of code; it adds a line here and there all over
script.js: in the key handler, in the explosion code,
in the animation loop. If an answer breaks something, it is much
easier to go back to your saved version and ask again than to find
the one wrong line among twenty changes.
Your prompt
Again you write the prompt yourself. The template below is a
starting point: replace every [BRACKET] with your own
words, delete the lines for effects you do not want, and paste your
three files at the end. The reference version further down is one
possible answer to one possible prompt. Your game will look and
sound different, and that is the point.
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 7: polish. Please add:
1. [THE FIRST EFFECT: what you see or hear, when it happens, how long it
lasts]
2. [THE SECOND EFFECT]
3. [...]
Sound: made with the Web Audio API only (no sound files). Create or
resume the AudioContext on the first key press or click, never on page
load. Sounds: [WHICH EVENTS MAKE WHICH SOUND, e.g. a short square-wave
"pew", a noise burst for a broken rock, a low engine hum]. [A KEY AND/OR
BUTTON] turns the sound on and off.
Pause: [WHICH KEYS], and pause by itself when the window loses focus or
the tab is hidden. While paused nothing moves and the engine is silent.
Reduced motion: [WHAT TO TONE DOWN when the visitor's system asks for
less motion].
Keep pure logic ([NAME THEM]) in small functions without Three.js or the
DOM in the rules section. Reuse the existing particle pool; create
nothing new while the game runs. Comment for a beginner.
[paste index.html, style.css and script.js here]
The template asks for all three files complete again, which makes
pasting simple. With a long script.js, some assistants
stop in the middle of their answer. If that happens, ask it to
continue, or ask for only the changed parts with clear markers of
where they go.
What to mention
- Each effect with its trigger and its length: "an amber light flash at the rock that fades in a fraction of a second", not "more juice".
- Sound: the Web Audio API only, the AudioContext created or resumed on the first key press or click (browsers keep it silent until then), and a mute key.
- Which sound belongs to which event, and that the sounds must be quiet. Game sounds get tiring fast.
- The pause keys, an automatic pause when the window loses focus or the tab is hidden, what the pause screen says and how to resume.
- Particles from the existing pool (a flame trail, bigger explosions), and no new objects per frame.
- Text on screen (like "+10"): as HTML positioned over the 3D point, or as a 3D object. Say which.
- Fullscreen, if you want it, and that it may not be allowed inside a frame (then the button should be hidden).
- People who prefer reduced motion: fewer flashes, no shaking.
Three hints
-
Sound starts with a gesture. If the AI creates
the AudioContext when the page loads, the browser keeps it
"suspended" and you hear nothing, and nothing throws an error (at
most the console shows a warning). The fix is to create it, or
call
resume(), inside the first keydown or click handler. Ask the AI to explain where that happens in its code. -
Pause must stop time, not only the drawing. If
the game keeps counting time while paused, rocks jump forward when
you resume. A simple way: when paused, the loop only renders and
returns.
THREE.Timerwithconnect(document)already gives a delta of 0 while the tab is hidden, but a window that only lost focus still runs, so pause onblurtoo. -
Additive blending is your glow. Flames, the
engine glow, bullets, warning lights and explosion pixels all look
brighter with
blending: THREE.AdditiveBlendinganddepthWrite: false. Black then means "invisible", which makes fading out easy: fade the color to black. A soft round glow texture can be made from your own pixels withTHREE.DataTexture, so you need no image file.
What you should see
In the reference version:
- Silence until your first key press or click, then a quiet engine hum that rises when you move.
- A "pew" for every shot, a muffled boom for every rock, a rising tone on level up.
- M or the SOUND: ON/OFF button mutes everything, including the hum.
- P or Escape shows PAUSED; everything freezes; P, Enter or RESUME continues. Clicking outside the game pauses it too.
- A short orange flame trail of pixels behind the jet, and a soft glow at the engine.
- Far rocks have a red warning glow that fades as they come close.
- Each broken rock: an amber light flash on the nearby rocks and the jet, and "+10" (or more with a combo) floating up from the spot.
-
A fullscreen button
[ ]in CodePen; in this site's game frame it is hidden, because fullscreen is not allowed there.
If it goes wrong
The general method (exact error, one fix at a time, back to the saved version when fixes pile up) is on Debugging with AI. Problems typical for this step:
- No sound at all. Look in the console for a message such as "The AudioContext was not allowed to start". Make sure the context is created or resumed in a key or click handler, and that the sound is not muted (in the game, and the browser tab itself).
-
A loud click when a sound starts or stops. The
volume jumps. Ask for a short fade with
setTargetAtTimeorexponentialRampToValueAtTime; an exponential ramp cannot target exactly 0 (the browser throws an error), so it should end at a small value such as 0.001. - Rocks jump after a pause. Time kept running while the game was paused. See hint 2.
-
"+10" appears in the wrong place. A 3D position
must be projected with the camera
(
vector.project(camera)) and then turned from the range −1 to 1 into pixels. - Everything glows white. Too many additive layers on top of each other, or colors that are too bright. Ask for lower opacity or color values.
-
The fullscreen button throws an error.
requestFullscreen()returns a promise that can be rejected inside a frame. Ask the AI to catch it, and to hide the button whendocument.fullscreenEnabledis false.
The reference version
This is one possible result of step 7: the version tested for this
page, built against Three.js r186 (three@0.186.1, as of
October 2026; with the bare "three" import, CodePen
adds the latest version itself). Play runs it right here. Below it
are the three panels to paste into a Pen: the HTML goes inside
<body>, then the CSS and the JavaScript. Click in
the game once before you use the keyboard. The game needs WebGL 2;
without it the frame stays blank.
This frame is sandboxed, so some things behave differently than in CodePen. It has no network, so external fonts are not loaded (the site supplies the pixel font itself); it has no storage, so the high score is not remembered; and fullscreen is not allowed, so the button is hidden. Sound, as in CodePen, waits for your first key press or click in the frame.
<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 id="overlay-small" 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>
<button id="sound" type="button">SOUND: ON</button>
<button id="fullscreen" type="button" aria-label="Fullscreen">[ ]</button>
</div>
<div id="popups" class="popups" aria-hidden="true"></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);
}
/* "+10" score popups, placed by script.js over the broken rock. */
.popups span {
position: fixed;
left: 0;
top: 0;
font: 12px/1 var(--font);
color: var(--amber);
text-shadow: 0 0 8px var(--flame);
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 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
const WARN_FAR = -60; // a far rock glows red...
const WARN_NEAR = -30; // ...and the glow is gone when it gets this close
const TRAIL_RATE = 70; // flame pixels per second from the engine
const FLASH_POWER = 80; // brightness of the light flash of an explosion
const FLASH_DECAY = 9; // how quickly a flash fades
const POPUP_LIFE = 0.8; // seconds a "+10" floats up
// 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;
}
}
// How strongly a rock's red warning glows: 1 far away, 0 up close.
function warningGlow(z) {
return clamp((WARN_NEAR - z) / (WARN_NEAR - WARN_FAR), 0, 1);
}
// A soft round spot as RGBA pixels: white, fading out from the middle.
function glowPixels(size) {
const pixels = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const d = Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) / (size / 2);
const alpha = Math.max(0, 1 - d) ** 2;
pixels.set([255, 255, 255, Math.round(alpha * 255)], (y * size + x) * 4);
}
}
return pixels;
}
// Fades smoothly: the same share is lost every second.
function decay(value, rate, dt) {
return value * Math.exp(-rate * dt);
}
// Three.js screen coordinates (-1 to 1, y up) -> CSS pixels (y down).
function toScreen(ndcX, ndcY, width, height) {
return { x: ((ndcX + 1) / 2) * width, y: ((1 - ndcY) / 2) * height };
}
// P switches between "playing" and "paused"; other states stay.
function togglePause(state) {
if (state === "playing") return "paused";
if (state === "paused") return "playing";
return state;
}
// White noise for explosions: random numbers from -1 to 1.
function noiseSamples(count, random) {
return Float32Array.from({ length: count }, () => random() * 2 - 1);
}
// The engine hum: deeper and quieter when slow, higher when fast.
function engineHum(speed) {
return {
frequency: 55 + speed * 4,
volume: 0.015 + Math.min(speed, 20) * 0.001,
};
}
// ---- 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);
// A soft glowing spot, used for the engine glow and the rock warnings.
// A Sprite always faces the camera; this texture is made from our own
// pixels, so it needs no image file.
const glowMap = new THREE.DataTexture(glowPixels(64), 64, 64);
glowMap.needsUpdate = true;
const glowSprite = (color, size) => {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: glowMap,
color,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false, // glows shine through the fog
}),
);
sprite.scale.setScalar(size);
return sprite;
};
// ---- 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");
const engineGlow = glowSprite(PALETTE.flame, 1.6);
engineGlow.position.z = 1.5;
ship.add(engineGlow);
// 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);
wakeAudio(); // a key press may start sound
if (event.code === "Enter") overlayAction();
if (event.code === "Space") restart();
if (event.code === "KeyP" || event.code === "Escape") {
setPaused(state === "playing");
}
if (event.code === "KeyM") toggleSound();
});
window.addEventListener("keyup", (event) => held.delete(event.code));
// When the game loses focus we never hear the keyup, so forget all keys,
// and pause, so nobody crashes while they are away.
window.addEventListener("blur", () => {
held.clear();
setPaused(true);
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) setPaused(true);
});
// ---- 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";
const warning = glowSprite(0xff3a2a, 4); // red, while it is far away
warning.name = "warning";
rock.add(warning);
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;
tone(1200, 400, 0.07, "square", 0.03); // pew
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;
}
}
// ---- Light flashes: one light that jumps to each explosion ----
const flash = new THREE.PointLight(PALETTE.amber, 0, 40);
scene.add(flash);
// Fewer flashes for people who asked their system for less motion.
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function flashAt(position) {
flash.position.copy(position);
flash.intensity = calm ? FLASH_POWER * 0.2 : FLASH_POWER;
}
// ---- "+10" popups: a few HTML spans, reused ----
const popupLayer = document.querySelector("#popups");
const popups = Array.from({ length: 8 }, () => {
const span = document.createElement("span");
span.hidden = true;
popupLayer.append(span);
return { span, life: 0, x: 0, y: 0 };
});
let nextPopup = 0;
const onScreen = new THREE.Vector3(); // reused
function popup(text, position) {
onScreen.copy(position).project(camera); // 3D -> -1..1 on screen
const at = toScreen(
onScreen.x,
onScreen.y,
window.innerWidth,
window.innerHeight,
);
const item = popups[nextPopup];
nextPopup = (nextPopup + 1) % popups.length;
Object.assign(item, { life: POPUP_LIFE, x: at.x, y: at.y });
item.span.textContent = text;
item.span.hidden = false;
}
function updatePopups(dt) {
for (const item of popups) {
if (item.life <= 0) continue;
item.life -= dt;
const rise = (POPUP_LIFE - item.life) * 60; // float up 60 px per second
item.span.style.transform = `translate(${item.x}px, ${item.y - rise}px)`;
item.span.style.opacity = fade(item.life, POPUP_LIFE);
if (item.life <= 0) item.span.hidden = true;
}
}
// ---- Sound: a small synthesizer made with the Web Audio API ----
// Browsers allow sound only after a key press or click, so the
// AudioContext is made (or woken up) then, not when the page loads.
let audio = null;
let muted = false;
let noiseBuffer = null;
let hum = null; // the engine: an oscillator that keeps running
function wakeAudio() {
if (!audio && window.AudioContext) {
audio = new window.AudioContext();
const seconds = audio.sampleRate; // one second of noise, reused
noiseBuffer = audio.createBuffer(1, seconds, audio.sampleRate);
noiseBuffer.getChannelData(0).set(noiseSamples(seconds, Math.random));
startHum();
}
if (audio && audio.state === "suspended") audio.resume();
}
// A tone that slides from one pitch to another and fades out.
function tone(fromHz, toHz, seconds, wave, volume) {
if (!audio || muted) return;
const now = audio.currentTime;
const oscillator = audio.createOscillator();
const level = audio.createGain();
oscillator.type = wave; // "square" sounds retro, "sine" soft
oscillator.frequency.setValueAtTime(fromHz, now);
oscillator.frequency.exponentialRampToValueAtTime(toHz, now + seconds);
level.gain.setValueAtTime(volume, now);
level.gain.exponentialRampToValueAtTime(0.001, now + seconds);
oscillator.connect(level).connect(audio.destination);
oscillator.start(now);
oscillator.stop(now + seconds);
}
// A burst of noise through a low-pass filter: a muffled boom.
function boom(seconds, volume, cutoffHz) {
if (!audio || muted) return;
const now = audio.currentTime;
const source = audio.createBufferSource();
source.buffer = noiseBuffer;
const filter = audio.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(cutoffHz, now);
const level = audio.createGain();
level.gain.setValueAtTime(volume, now);
level.gain.exponentialRampToValueAtTime(0.001, now + seconds);
source.connect(filter).connect(level).connect(audio.destination);
source.start(now);
source.stop(now + seconds);
}
function startHum() {
const oscillator = audio.createOscillator();
oscillator.type = "sawtooth";
const filter = audio.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 280;
const level = audio.createGain();
level.gain.value = 0;
oscillator.connect(filter).connect(level).connect(audio.destination);
oscillator.start();
hum = { oscillator, level };
}
function updateHum(speed) {
if (!hum) return;
const { frequency, volume } = engineHum(speed);
const on = !muted && state === "playing";
// setTargetAtTime glides to the new value, so the hum never clicks.
hum.oscillator.frequency.setTargetAtTime(frequency, audio.currentTime, 0.1);
hum.level.gain.setTargetAtTime(on ? volume : 0, audio.currentTime, 0.1);
}
function toggleSound() {
muted = !muted;
document.querySelector("#sound").textContent = muted
? "SOUND: OFF"
: "SOUND: ON";
}
// ---- Game state: "countdown", "playing", "paused" 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!";
}
$("#overlay-small").textContent = "OR PRESS ENTER";
showOverlay("GAME OVER", text, "PLAY AGAIN");
flashAt(ship.position);
boom(1.2, 0.35, 500);
}
// 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);
flashAt(rock.mesh.position);
tone(700, 120, 0.4, "sine", 0.15);
boom(0.4, 0.2, 1200);
}
// 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;
popup(`+${POINTS_PER_ROCK * combo}`, rock.mesh.position);
flashAt(rock.mesh.position);
boom(0.45, 0.25, 900);
kills += 1;
const before = level;
level = levelFor(kills);
if (level > before) tone(440, 880, 0.3, "square", 0.05); // level up!
}
// 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;
}
// P, Escape or leaving the game window: pause.
function setPaused(paused) {
if (paused !== (state === "playing")) return; // nothing to switch
state = togglePause(state);
held.clear();
if (paused) {
$("#overlay-small").textContent = "OR PRESS P";
showOverlay("PAUSED", "TAKE A BREATH", "RESUME");
} else {
overlay.hidden = true;
}
}
// The overlay button and Enter: restart after GAME OVER, else resume.
function overlayAction() {
if (state === "over") restart();
else setPaused(false);
}
$("#overlay-button").addEventListener("click", overlayAction);
$("#sound").addEventListener("click", (event) => {
event.currentTarget.blur();
toggleSound();
});
// Fullscreen, where the browser allows it (not inside every frame).
const fullscreenButton = $("#fullscreen");
fullscreenButton.hidden = !document.fullscreenEnabled;
fullscreenButton.addEventListener("click", (event) => {
event.currentTarget.blur();
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen().catch(() => {});
});
$("#restart").addEventListener("click", (event) => {
event.currentTarget.blur(); // or Space (fire!) would press it again
restart(true);
});
canvas.addEventListener("pointerdown", () => (clicked = true)); // click = fire
window.addEventListener("pointerdown", wakeAudio); // a click may start sound
// 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;
engineGlow.scale.setScalar(1.4 + 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;
rock.mesh.children[0].material.opacity = warningGlow(rock.mesh.position.z);
// 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);
}
}
}
// The flame trail: a few orange pixels from the engine every frame.
let trailDue = 0; // pixels owed (a frame can be worth half a pixel)
function updateTrail(dt) {
trailDue += TRAIL_RATE * dt;
for (; trailDue >= 1; trailDue -= 1) {
const particle = particles[nextParticle];
particle.velocity = {
x: (Math.random() - 0.5) * 1.5,
y: (Math.random() - 0.5) * 1.5,
z: 8 + Math.random() * 4, // backward, toward the camera
};
particle.life = particle.maxLife = 0.25 + Math.random() * 0.15;
particle.rgb = hexToRgb(
Math.random() < 0.5 ? PALETTE.flame : PALETTE.amber,
);
const { x, y, z } = ship.position;
particlePositions.set([x, y, z + 1.5], nextParticle * 3);
nextParticle = (nextParticle + 1) % PARTICLE_POOL;
}
}
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
// Paused: draw the same picture again, change nothing.
if (state === "paused") {
updateHum(0); // state is "paused", so this silences the engine
renderer.render(scene, camera);
return;
}
// 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);
if (state !== "over") updateTrail(dt);
updateParticles(dt);
updatePopups(dt);
updateHum(Math.hypot(velocity.x, velocity.y));
flash.intensity = decay(flash.intensity, FLASH_DECAY, 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. They are not the only way to do it, but they
show what to look for when you read your own AI's version.
Sound after the first gesture
The Web Audio API is the browser's built-in way to make and process sound in JavaScript: you connect small building blocks (sources, filters, volume controls) into a chain that ends at the speakers. Everything starts from an AudioContext, the object that owns the audio clock and the output; a page normally needs only one.
Browsers do not let a page start making noise by itself. This
autoplay policy has applied
to Web Audio in Chrome since Chrome 71: an AudioContext created
before the visitor has interacted with the page starts in the
"suspended" state, and the page has to call resume()
after a user gesture such as a key press or click. MDN's advice for
all browsers is the same: create or resume the context from inside a
user gesture. A suspended context makes no sound and throws no error
(at most the console shows a warning), which is why "no sound at
all" is such a common symptom. The reference game therefore creates
the context in the first key or click handler, and resumes it if
needed:
function wakeAudio() {
if (!audio && window.AudioContext) {
audio = new window.AudioContext();
const seconds = audio.sampleRate; // one second of noise, reused
noiseBuffer = audio.createBuffer(1, seconds, audio.sampleRate);
noiseBuffer.getChannelData(0).set(noiseSamples(seconds, Math.random));
startHum();
}
if (audio && audio.state === "suspended") audio.resume();
}
wakeAudio() is called at the top of the keydown handler
and on every pointerdown. The check
window.AudioContext means a browser without Web Audio
simply has a silent game instead of a crash.
Tones and booms
An oscillator is a sound source that produces a repeating waveform at a given frequency: a sine wave sounds soft, a square wave sounds like an old game console. Each "pew" is a new oscillator that slides from one pitch to another and fades out:
function tone(fromHz, toHz, seconds, wave, volume) {
if (!audio || muted) return;
const now = audio.currentTime;
const oscillator = audio.createOscillator();
const level = audio.createGain();
oscillator.type = wave; // "square" sounds retro, "sine" soft
oscillator.frequency.setValueAtTime(fromHz, now);
oscillator.frequency.exponentialRampToValueAtTime(toHz, now + seconds);
level.gain.setValueAtTime(volume, now);
level.gain.exponentialRampToValueAtTime(0.001, now + seconds);
oscillator.connect(level).connect(audio.destination);
oscillator.start(now);
oscillator.stop(now + seconds);
}
The gain node is the volume control. Its value is changed with a
ramp, not set directly, because a sudden jump in volume is heard as
a click. The ramp ends at 0.001 rather than 0 because an exponential
ramp cannot target exactly zero (the browser throws an error).
Explosions use the same idea with a different source: one second of
random noise, made once in
wakeAudio(), played through a low-pass filter that
removes the hiss and leaves a muffled boom. The random numbers come
from noiseSamples(), a pure function in the rules
section. The engine hum is one oscillator that keeps running; its
pitch and volume glide with setTargetAtTime as the
ship's speed changes.
A pause that stops time
The pause is a fourth game state next to "countdown", "playing" and "over" (states arrived in step 4). In the animation loop, a paused game draws the same picture again and returns before anything moves:
// Paused: draw the same picture again, change nothing.
if (state === "paused") {
updateHum(0); // state is "paused", so this silences the engine
renderer.render(scene, camera);
return;
}
Two listeners start the pause by themselves: one for a window that loses focus (you clicked outside the game) and one for a hidden tab. Losing focus also means the game never hears the key being let go, so the held keys are forgotten:
window.addEventListener("blur", () => {
held.clear();
setPaused(true);
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) setPaused(true);
});
Light, glow and reduced motion
The explosion flash is a single point light that jumps to each
explosion and fades with decay(), a pure function that
loses the same share every second, so it fades equally fast at 60
and at 144 frames per second. People who asked their system for less
motion get a much weaker flash:
const flash = new THREE.PointLight(PALETTE.amber, 0, 40);
scene.add(flash);
// Fewer flashes for people who asked their system for less motion.
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function flashAt(position) {
flash.position.copy(position);
flash.intensity = calm ? FLASH_POWER * 0.2 : FLASH_POWER;
}
matchMedia reads the same setting as the CSS media
feature
prefers-reduced-motion. Flashing deserves care beyond preference: WCAG has a success
criterion against content that flashes more than three times a
second, because flashing can trigger seizures (see
motion and WCAG). A game with many explosions in a row comes close to that, so
toning flashes down is a sensible default.
The engine glow and the red warnings are sprites (flat images that
always face the camera) with a soft round texture made from the
game's own pixels by glowPixels(), so there is no image
file to load. The flame trail takes a few pixels per frame from the
explosion pool of step 5; nothing new is created while the game
runs.
Points that pop up
The "+10" is HTML, not 3D: a few <span> elements
made once and reused. To place one over a broken rock, the rock's 3D
position is projected with the camera into the range −1 to 1, and a
pure function turns that into pixels (the y axis flips, because
screen pixels count downward):
// Three.js screen coordinates (-1 to 1, y up) -> CSS pixels (y down).
function toScreen(ndcX, ndcY, width, height) {
return { x: ((ndcX + 1) / 2) * width, y: ((1 - ndcY) / 2) * height };
}
The text is set with textContent, never
innerHTML, the same safe habit as in the other steps
(see
textContent versus innerHTML). The next step is yours to design:
Your Perfect Flight.