Build 2 · A Ship You Can Fly
A low-poly jet built from Three.js shapes that follows the mouse and the keyboard, leans into its turns and moves at the same speed on every screen, thanks to delta time.
What you add
The starfield from step 1 gets a pilot: a small jet that waits in the lower middle of the screen, seen from behind. It is built from simple Three.js shapes, follows the pointer smoothly, rolls into sideways moves and lifts its nose when it climbs. This is the first step that changes a working program instead of starting a new one, so it is also the first real test of prompting in small steps: one feature, described precisely, on top of a version that works.
- The ship, built from about twenty simple shapes.
- Steering with the mouse, a finger, the arrow keys and WASD.
- Smooth movement that feels the same on a 60 Hz and a 144 Hz screen.
- A help line at the bottom of the screen.
Before you start
Check that your step 1 Pen works (stars streaming, no errors in the
console), then save it and copy the contents of
script.js into a text file. If this step goes wrong,
that copy is your way back. Better ways to keep versions are on
Saving Versions and Going Back.
The prompt
Open a new chat, or continue the one from step 1. Copy the prompt
below and replace its last line,
[paste index.html, style.css and script.js here], with
the current contents of your three files. Sending the code along
matters: the assistant cannot see your Pen, and the files you paste
are the only version of the game it knows. Then replace all three
files with the answer.
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 2: a ship you can fly.
The ship, a "retro jet", built from Three.js geometries in one THREE.Group
named "ship", nose pointing ahead (negative z), low-poly with flatShading:
- a cream body (#e8e2d0) made with LatheGeometry from a side profile,
- a teal nose cone (#1f7a8c), teal swept wings made with Shape +
ExtrudeGeometry, and a teal tail fin,
- two copper rings around the body (#b87333), a glass cockpit canopy
(half a sphere, see-through, icy #d8f7ff),
- thin neon cyan lines (#7fe3f5) along the body and the wings,
- a small dark gun barrel on each wing tip,
- an engine bell at the back with an orange flame cone (#ff7a1a) that
uses additive blending and flickers in length.
Add an ambient light, a white directional light and a cyan rim light.
Flying:
- The ship stays at z = 0 and follows the mouse pointer: turn the pointer
position into a target point at the ship's depth, and ease toward it
(exponential easing that feels the same at any frame rate).
- Before the first mouse or key input, the ship waits in the lower middle
of the screen: centered, halfway between the middle and the bottom.
- Keep the target inside the visible area minus a margin, so the wing tips
never leave the screen. Recompute these bounds on resize.
- The arrow keys and WASD also move the target. Use event.code, and call
preventDefault for the arrow keys and Space so the page never scrolls.
Forget all held keys when the window loses focus (blur).
- The ship rolls when it moves sideways and pitches its nose up when it
climbs, by an amount based on its speed, with a maximum.
- Make the canvas touch-action: none, so a finger can steer too.
- Keep the starfield.
- Add a hint at the bottom: "MOVE THE MOUSE (OR ARROWS / WASD) TO FLY".
Keep the pure logic (the bounds, pointer to target, start point, easing,
tilt, key direction) in small functions without Three.js or the DOM in
the rules section at the top. Comment for a beginner.
[paste index.html, style.css and script.js here]
Notice how much of the prompt is about how: exponential
easing "that feels the same at any frame rate",
event.code, preventDefault, the logic in
small functions at the top. Left out, each of these is a decision
the model makes for you, often in the way older tutorials did it.
The section How it works below explains
what each one does.
What you should see
- Before you touch the mouse or the keys: a cream-and-teal jet in the lower middle of the screen, seen from behind and a little above.
- A flickering orange flame at its tail.
- Move the mouse: the jet glides after it, not jerkily, and stops smoothly.
- Move fast to the right: the jet rolls to the right (right wing down), then levels out.
- Move up: the nose lifts a little.
- Push the pointer to every edge: the wing tips stay on screen.
- Click the game once, then try the arrow keys and WASD: the jet moves and the page does not scroll.
- Stars still stream past.
If it goes wrong
- The keys do nothing: click inside the game first. Keys only go to the focused frame, and CodePen's preview is a frame of its own.
- The jet flies backward or is upside down: the nose must point to negative z. Ask the AI which way each part was rotated, and to fix the rotation, not to add extra rotations on top.
-
Wings look black or invisible from one side: the
material may need
side: THREE.DoubleSide, or the extruded shape is facing away. Ask the AI to explain which side is drawn. - The jet leaves the screen on a wide or narrow preview: the bounds must be computed from the camera's field of view and aspect ratio, and updated in the resize handler.
-
The ship moves faster on a 144 Hz screen: the
easing ignores delta time. Ask for easing with
Math.exp(-rate * dt). - The AI changed the camera or removed the stars: say "keep everything that already works" again, and paste your last working version back if needed.
The reference version
Below is one possible result of this step: the tested reference
version. Press Play to run it right here; the frame takes the
keyboard focus when it starts, and if the keys do not respond, click
in the game once. The code under the frame is the three parts to
paste into a Pen: the HTML goes inside
<body>, then the CSS and the JavaScript. This
frame is sandboxed, so a few things behave differently from CodePen:
it has no network access (external fonts are not loaded; the site
supplies the pixel font itself) and no storage, and in later steps
the high score is not remembered and the fullscreen button is
hidden.
<canvas id="game"></canvas>
<div class="hud">
<span class="title">RETRO JET</span>
<span>STEP 2 · FLY</span>
</div>
<p class="hint">MOVE THE MOUSE (OR ARROWS / WASD) TO FLY</p>
/* The colors of the game, in one place. script.js uses the same values. */
:root {
--space: #0a1220;
--hull: #e8e2d0;
--teal: #1f7a8c;
--cyan: #7fe3f5;
--cyan-deep: #2fa8c0;
--ice: #d8f7ff;
--copper: #b87333;
--amber: #ffc890;
--flame: #ff7a1a;
--metal: #2e3036;
/* A pixel font if you add it (see the step text); otherwise Courier New. */
--font: "Press Start 2P", "Courier New", monospace;
}
/* The game fills the whole preview; nothing scrolls. */
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background: var(--space);
}
#game {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none; /* a finger drags the ship instead of the page */
}
/* HUD: small pixel-style text on top of the game. */
.hud {
position: fixed;
inset: 0 0 auto 0;
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 12px 16px;
font: 11px/1.6 var(--font);
letter-spacing: 0.1em;
color: var(--ice);
pointer-events: none; /* clicks go through to the game */
}
.hud .title {
color: var(--cyan);
}
/* A short help line at the bottom. */
.hint {
position: fixed;
left: 0;
right: 0;
bottom: 14px;
margin: 0;
text-align: center;
font: 9px/1.6 var(--font);
letter-spacing: 0.1em;
color: var(--cyan-deep);
pointer-events: none;
}
import * as THREE from "three";
// ---- GAME RULES (pure functions: no Three.js, no DOM) ----
// Plain numbers and small functions that only do math, so they are easy
// to read, to change and to test.
// The camera sits behind and a little above the action, looking ahead.
const CAMERA_POSITION = { x: 0, y: 1.4, z: 8 };
const CAMERA_TARGET = { x: 0, y: 0.6, z: -12 };
const FIELD_OF_VIEW = 60; // degrees, top to bottom
const SPACE_COLOR = 0x0a1220; // same as --space in the CSS
const STAR_COUNT = 1800;
const STAR_FAR = -190; // stars start this far ahead (negative z = ahead)...
const STAR_NEAR = 12; // ...and wrap around once they pass the camera
const STAR_MIN_RADIUS = 3; // an empty tube around the flight path
const STAR_MAX_RADIUS = 70;
const STAR_SPEED = 30; // units per second toward the camera
const MAX_STEP = 0.05; // longest time step we accept, in seconds
// The ship's colors (the same values as the CSS custom properties).
const PALETTE = {
hull: 0xe8e2d0,
teal: 0x1f7a8c,
cyan: 0x7fe3f5,
cyanDeep: 0x2fa8c0,
ice: 0xd8f7ff,
copper: 0xb87333,
amber: 0xffc890,
flame: 0xff7a1a,
metal: 0x2e3036,
};
const SHIP_FOLLOW = 6; // how quickly the ship catches up (higher = snappier)
const KEY_SPEED = 9; // units per second when steering with the keys
const WING_MARGIN = 1.9; // keep the wing tips on screen
const ROLL = 0.09; // radians of roll per unit/second of sideways speed
const PITCH = 0.05; // radians of pitch per unit/second of climb
const MAX_TILT = 0.7; // never tilt more than this (radians)
// 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 };
}
// ---- END OF GAME RULES ----
// ---- Renderer, scene and camera ----
const canvas = document.querySelector("#game");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
scene.background = new THREE.Color(SPACE_COLOR);
const camera = new THREE.PerspectiveCamera(FIELD_OF_VIEW, 1, 0.1, 400);
camera.position.set(CAMERA_POSITION.x, CAMERA_POSITION.y, CAMERA_POSITION.z);
camera.lookAt(CAMERA_TARGET.x, CAMERA_TARGET.y, CAMERA_TARGET.z);
// 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;
const stars = new THREE.Points(starGeometry, starMaterial);
stars.frustumCulled = false; // the stars move, so always draw them
scene.add(stars);
// ---- The ship: the "retro jet", built from simple shapes ----
// The nose points ahead (-z). Every part is a Mesh added to one Group, so
// moving or turning the group moves the whole ship.
function buildShip() {
const ship = new THREE.Group();
ship.name = "ship";
// flatShading shows every face as one flat color: the low-poly look.
const look = (color, extra) =>
new THREE.MeshStandardMaterial({ color, flatShading: true, ...extra });
const hull = look(PALETTE.hull, { roughness: 0.6 });
const teal = look(PALETTE.teal, { roughness: 0.5 });
const copper = look(PALETTE.copper, { metalness: 0.6, roughness: 0.35 });
const metal = look(PALETTE.metal, { metalness: 0.5, side: THREE.DoubleSide });
const neon = new THREE.MeshBasicMaterial({ color: PALETTE.cyan }); // no light needed: it glows
const add = (geometry, material, x = 0, y = 0, z = 0) => {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z);
ship.add(mesh);
return mesh;
};
// Body: a side profile (radius at each point along the length), turned
// around an axis like wood on a lathe.
const profile = [
[0, -1.2],
[0.3, -1.15],
[0.42, -0.7],
[0.44, 0.1],
[0.36, 0.9],
[0.2, 1.35],
[0, 1.6],
].map(([radius, along]) => new THREE.Vector2(radius, along));
const body = new THREE.LatheGeometry(profile, 12);
body.rotateX(-Math.PI / 2); // the lathe axis was y; now the nose points ahead
add(body, hull);
const nose = new THREE.ConeGeometry(0.22, 0.45, 12);
nose.rotateX(-Math.PI / 2);
add(nose, teal, 0, 0, -1.5);
// Copper rings around the body, and neon lines along its sides.
for (const z of [-0.55, 0.5])
add(new THREE.TorusGeometry(0.43, 0.035, 6, 24), copper, 0, 0, z);
for (const side of [-1, 1])
add(new THREE.BoxGeometry(0.03, 0.03, 1.3), neon, side * 0.41, 0.1, -0.05);
// Cockpit: half a sphere, stretched, made of see-through glass.
const glass = look(PALETTE.ice, {
transparent: true,
opacity: 0.6,
roughness: 0.1,
emissive: PALETTE.cyanDeep,
emissiveIntensity: 0.25,
});
const canopy = add(
new THREE.SphereGeometry(0.24, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2),
glass,
0,
0.3,
-0.45,
);
canopy.scale.set(1, 0.8, 2.2);
// Swept wings: a flat outline (Shape), given thickness (Extrude).
for (const side of [-1, 1]) {
const outline = new THREE.Shape();
outline.moveTo(side * 0.3, 0.5);
outline.lineTo(side * 1.7, -0.45);
outline.lineTo(side * 1.7, -0.75);
outline.lineTo(side * 0.3, -0.6);
outline.closePath();
const wing = new THREE.ExtrudeGeometry(outline, {
depth: 0.07,
bevelEnabled: false,
});
wing.rotateX(-Math.PI / 2); // lay it flat, front edge ahead
add(wing, teal, 0, -0.08, 0);
// A neon line along the front edge of the wing.
const edge = add(
new THREE.BoxGeometry(1.69, 0.03, 0.03),
neon,
side * 1.0,
0,
-0.03,
);
edge.rotation.y = -side * Math.atan2(0.95, 1.4);
// A small gun barrel on the wing tip, with a copper tip.
const barrel = new THREE.CylinderGeometry(0.04, 0.04, 0.6, 6);
barrel.rotateX(Math.PI / 2);
add(barrel, metal, side * 1.7, -0.02, 0.2);
add(
new THREE.BoxGeometry(0.1, 0.1, 0.08),
copper,
side * 1.7,
-0.02,
-0.12,
);
}
// Tail fin: another flat outline, standing up.
const finOutline = new THREE.Shape();
finOutline.moveTo(0, 0);
finOutline.lineTo(0.75, 0);
finOutline.lineTo(0.85, 0.6);
finOutline.lineTo(0.55, 0.6);
finOutline.closePath();
const fin = new THREE.ExtrudeGeometry(finOutline, {
depth: 0.05,
bevelEnabled: false,
});
fin.rotateY(-Math.PI / 2); // stand it up, pointing back
add(fin, teal, 0.025, 0.3, 0.3);
// Engine: an open bell at the back, and an orange flame. Additive
// blending adds its color to what is behind it, so it seems to glow.
const bell = new THREE.CylinderGeometry(0.3, 0.2, 0.35, 12, 1, true);
bell.rotateX(Math.PI / 2);
add(bell, metal, 0, 0, 1.25);
const flameShape = new THREE.ConeGeometry(0.18, 0.9, 10);
flameShape.rotateX(Math.PI / 2); // tip pointing back
flameShape.translate(0, 0, 0.45); // wide end at z = 0, so it grows backward
const flameLook = new THREE.MeshBasicMaterial({
color: PALETTE.flame,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const flame = add(flameShape, flameLook, 0, 0, 1.4);
flame.name = "flame";
return ship;
}
const ship = buildShip();
const flame = ship.getObjectByName("flame");
scene.add(ship);
ship.position.y = startTarget(shipBounds(1)).y;
// ---- Steering: the ship follows a target point ----
// The mouse (or a finger) sets the target; the arrow keys or WASD move it.
let bounds = shipBounds(1);
let target = startTarget(bounds); // until the first mouse or key input
let velocity = { x: 0, y: 0 }; // only used to tilt the ship
window.addEventListener("pointermove", (event) => {
const ndcX = (event.clientX / window.innerWidth) * 2 - 1;
const ndcY = -(event.clientY / window.innerHeight) * 2 + 1; // y is up in 3D
target = pointerTarget(ndcX, ndcY, bounds);
});
// e.code names the physical key ("KeyW"), so WASD also works on an AZERTY
// keyboard. Arrow keys and Space would scroll the page, so we stop that.
const held = new Set();
window.addEventListener("keydown", (event) => {
if (event.code.startsWith("Arrow") || event.code === "Space") {
event.preventDefault();
}
held.add(event.code);
});
window.addEventListener("keyup", (event) => held.delete(event.code));
// When the game loses focus we never hear the keyup, so forget all keys.
window.addEventListener("blur", () => held.clear());
// ---- 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;
}
renderer.setAnimationLoop((time) => {
timer.update(time);
const dt = clampStep(timer.getDelta()); // seconds since the last frame
// Speeds are "per second" times dt, so the game runs at the same pace
// on a 60 Hz and a 144 Hz screen.
updateStars(dt);
updateShip(dt);
renderer.render(scene, camera);
});
How it works
The script has grown from about 110 to about 400 lines, most of it
the ship. Four ideas carry the step; the excerpts are copied from
the reference script.js. How to find your way around a
file this size is the subject of the next page,
Reading the Code You Got.
A ship from shapes
A
mesh is a visible 3D object in Three.js:
a geometry (the shape, as a set of triangles) combined with a
material (how its surface reacts to light: color, shininess,
transparency). The jet is about twenty meshes in one
THREE.Group, so moving or turning the group moves the
whole ship. The helper that adds each part (inside
buildShip()) shows the pattern:
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;
};
The body is a LatheGeometry: a side profile turned
around an axis, like wood on a lathe. Wings and fin are flat
outlines (Shape) given thickness with
ExtrudeGeometry. flatShading: true gives
every face one flat color, which is what makes it look low-poly.
Most parts use MeshStandardMaterial, which needs light,
so the scene gets three lights: a soft ambient light, a white "sun"
and a cyan rim light from ahead. The neon lines use
MeshBasicMaterial, which ignores the lights and always
shows its own color, so they seem to glow.
The animation loop
An
animation loop is the function
a game runs once for every frame the screen shows: it reads the
input, updates the state of the world and draws the new picture.
Browsers call it at about the display's
refresh rate,
for example 60, 120 or 144 times per second, and pause it in most
browsers when the tab or frame is hidden. In Three.js you hand that
function to renderer.setAnimationLoop(). Input does not
happen inside the loop: event listeners record it whenever it
arrives (the pointer's target, the keys held down), and the loop
reads that record each frame.
Delta time and easing
Delta time (often dt)
is the time that passed since the previous frame, usually in
seconds. Multiplying every speed by it ("units per second times
seconds") makes movement independent of the frame rate. MDN warns
that an animation that ignores this "will run faster on high
refresh-rate screens"; a runnable example on
Prompting in Small Steps
shows the difference: 30 units in one second at 60 Hz, 72 units at
144 Hz.
The ship does not jump to the pointer; it eases toward a target point. Easing has the same trap: "move 10% of the remaining gap each frame" means more steps, and so a faster ship, on a faster screen. The reference version uses exponential easing, in which the time step sits in the exponent:
// 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);
}
Whether that comment is true is something you can check rather than
believe; the next page,
Reading the Code You Got, runs this function at both frame rates. The ship's roll and pitch
come from its speed (tilt()), capped at 0.7 radians so
it never flips over.
Keys with event.code
KeyboardEvent.code is the
property of a keyboard event that names the physical key that was
pressed, such as "KeyW", "ArrowLeft" or
"Space", regardless of the keyboard layout. MDN notes
it is "especially common when writing code to handle input for
games". Its sibling event.key gives the character the
key produces in the current layout, so a game that checks
key === "w" breaks on a French AZERTY keyboard, where
that key types "z". Older code uses the numeric
event.keyCode; prefer code.
const held = new Set();
window.addEventListener("keydown", (event) => {
if (event.code.startsWith("Arrow") || event.code === "Space") {
event.preventDefault();
}
held.add(event.code);
});
window.addEventListener("keyup", (event) => held.delete(event.code));
// When the game loses focus we never hear the keyup, so forget all keys.
window.addEventListener("blur", () => held.clear());
The listeners only keep a set of the keys held down; the loop turns
that set into a direction each frame (keyDirection()).
preventDefault() stops the arrow keys and Space from
scrolling the page around the game (see
default actions), and the blur listener prevents a ship that keeps
flying left forever because you released the key while another
window had the focus. Listeners are added once, at the start, never
inside the loop.