Skip to content

Instantly share code, notes, and snippets.

@ivanfioravanti
Created June 14, 2026 16:01
Show Gist options
  • Select an option

  • Save ivanfioravanti/f3593754cf80732706ade58786231552 to your computer and use it in GitHub Desktop.

Select an option

Save ivanfioravanti/f3593754cf80732706ade58786231552 to your computer and use it in GitHub Desktop.
Lunar Lander Prompt
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lunar Lander GLM-5.2</title>
<style>
html, body {
margin: 0;
height: 100%;
background: #05060a;
color: #c8d3e6;
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
overflow: hidden;
}
h1 {
font-size: 14px;
letter-spacing: 4px;
color: #6b7a93;
margin: 0;
text-transform: uppercase;
}
#wrap {
position: relative;
width: min(96vw, 96vh * 1.5);
aspect-ratio: 3 / 2;
}
canvas {
width: 100%;
height: 100%;
display: block;
background: #05060a;
border: 1px solid #1c2433;
border-radius: 6px;
image-rendering: crisp-edges;
}
</style>
</head>
<body>
<h1>Lunar Lander GLM-5.2</h1>
<div id="wrap">
<canvas id="game" width="900" height="600"></canvas>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const VIEW = { W: canvas.width, H: canvas.height };
// ---- Physics constants (per fixed 1/60s step). Tuned for floaty lunar feel. ----
const GRAVITY = 0.030; // downward acceleration
const THRUST = 0.092; // main engine acceleration (along nose direction)
const ROT_SPEED = 0.045; // radians per step when turning
const FUEL_MAX = 100;
const BURN = 0.18; // fuel per step while thrusting (~9s of full burn)
const SAFE_VY = 1.5; // safe descent speed (vertical)
const SAFE_VX = 0.8; // safe horizontal speed
const TILT_MAX = 0.26; // ~15° landing tolerance
const LEG_SPREAD = 11; // lander foot x-offset (local)
const LEG_LEN = 14; // lander foot y-offset (local)
const DISPLAY_V = 10; // velocity multiplier for friendly "m/s" readout
// ---- Procedural audio (Web Audio API, no external files) ----
const Sound = (() => {
let ctx = null, master = null, noiseBuffer = null;
let thrustGain = null, lastThrust = false, rcsLast = 0, muted = false;
function init() {
if (ctx) return;
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain();
master.gain.value = muted ? 0 : 0.5;
master.connect(ctx.destination);
// Shared white-noise buffer for engine, crash, and RCS.
const len = ctx.sampleRate * 2;
noiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
// Continuous looping engine source, gain modulated by thrust.
const src = ctx.createBufferSource();
src.buffer = noiseBuffer;
src.loop = true;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 90;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 620;
thrustGain = ctx.createGain(); thrustGain.gain.value = 0;
src.connect(hp); hp.connect(lp); lp.connect(thrustGain); thrustGain.connect(master);
src.start();
}
function resume() {
if (!ctx) init();
if (ctx.state === "suspended") ctx.resume();
}
function setThrust(on) {
if (!ctx || on === lastThrust) return;
lastThrust = on;
const t = ctx.currentTime;
thrustGain.gain.cancelScheduledValues(t);
thrustGain.gain.setTargetAtTime(on ? 0.5 : 0.0, t, 0.04);
}
function land() {
if (!ctx) return;
const t0 = ctx.currentTime;
[523.25, 659.25, 783.99, 1046.5].forEach((f, i) => { // C5 E5 G5 C6
const t = t0 + i * 0.12;
const osc = ctx.createOscillator();
osc.type = "triangle"; osc.frequency.value = f;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.4, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
osc.connect(g); g.connect(master);
osc.start(t); osc.stop(t + 0.55);
});
}
function crash() {
if (!ctx) return;
const t = ctx.currentTime;
const src = ctx.createBufferSource(); src.buffer = noiseBuffer;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass";
lp.frequency.setValueAtTime(1400, t);
lp.frequency.exponentialRampToValueAtTime(120, t + 0.6);
const g = ctx.createGain();
g.gain.setValueAtTime(0.9, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
src.connect(lp); lp.connect(g); g.connect(master);
src.start(t); src.stop(t + 0.85);
const osc = ctx.createOscillator(); osc.type = "sine";
osc.frequency.setValueAtTime(130, t);
osc.frequency.exponentialRampToValueAtTime(38, t + 0.5);
const og = ctx.createGain();
og.gain.setValueAtTime(0.8, t);
og.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
osc.connect(og); og.connect(master);
osc.start(t); osc.stop(t + 0.65);
}
function rcs() {
if (!ctx) return;
const now = performance.now();
if (now - rcsLast < 110) return; // throttle the click train
rcsLast = now;
const t = ctx.currentTime;
const src = ctx.createBufferSource(); src.buffer = noiseBuffer;
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 900; bp.Q.value = 1.2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.16, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
src.connect(bp); bp.connect(g); g.connect(master);
src.start(t); src.stop(t + 0.13);
}
function toggle() {
muted = !muted;
if (master) master.gain.setTargetAtTime(muted ? 0 : 0.5, ctx.currentTime, 0.02);
}
const isMuted = () => muted;
return { resume, setThrust, land, crash, rcs, toggle, isMuted };
})();
// ---- Game state ----
let terrain, lander, particles, stars, state, reason;
const keys = { up: false, left: false, right: false };
// ---- Helpers ----
const rand = (a, b) => a + Math.random() * (b - a);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const normalizeAngle = (a) => {
a = a % (2 * Math.PI);
if (a < -Math.PI) a += 2 * Math.PI;
if (a > Math.PI) a -= 2 * Math.PI;
return a;
};
// ---- Terrain ----
function generateTerrain() {
const { W, H } = VIEW;
const padW = 96;
const padCenter = rand(W * 0.22, W * 0.78);
const padX1 = padCenter - padW / 2;
const padX2 = padCenter + padW / 2;
const padY = rand(H * 0.62, H * 0.8);
const pts = [];
// Left jagged edge
let y = rand(H * 0.55, H * 0.78);
let x = 0;
pts.push({ x, y });
while (x < padX1 - 24) {
x += rand(22, 50);
if (x >= padX1) break;
y = clamp(y + rand(-1, 1) * H * 0.09, H * 0.46, H * 0.88);
pts.push({ x, y });
}
// Flat landing pad
pts.push({ x: padX1, y: padY });
pts.push({ x: padX2, y: padY });
// Right jagged edge
x = padX2;
y = padY;
while (x < W) {
x += rand(22, 50);
if (x > W) x = W;
y = clamp(y + rand(-1, 1) * H * 0.09, H * 0.46, H * 0.88);
pts.push({ x, y });
if (x >= W) break;
}
if (pts[pts.length - 1].x < W) pts.push({ x: W, y: pts[pts.length - 1].y });
return { pts, padX1, padX2, padY };
}
function terrainYAt(x) {
const p = terrain.pts;
if (x <= p[0].x) return p[0].y;
for (let i = 0; i < p.length - 1; i++) {
if (x >= p[i].x && x <= p[i + 1].x) {
const span = p[i + 1].x - p[i].x || 1;
const t = (x - p[i].x) / span;
return p[i].y + (p[i + 1].y - p[i].y) * t;
}
}
return p[p.length - 1].y;
}
// ---- Stars ----
function makeStars() {
const arr = [];
for (let i = 0; i < 90; i++) {
arr.push({
x: Math.random() * VIEW.W,
y: Math.random() * VIEW.H * 0.7,
r: Math.random() < 0.85 ? 0.7 : 1.4,
a: rand(0.25, 0.9),
});
}
return arr;
}
// ---- Lander geometry helpers ----
function rotateLocal(lx, ly) {
const c = Math.cos(lander.angle), s = Math.sin(lander.angle);
return { x: lander.x + lx * c - ly * s, y: lander.y + lx * s + ly * c };
}
const footWorld = (side) => rotateLocal(side * LEG_SPREAD, LEG_LEN);
// ---- Reset / restart ----
function reset() {
Sound.setThrust(false);
terrain = generateTerrain();
lander = {
x: VIEW.W * 0.5 + rand(-40, 40),
y: 70,
vx: rand(-0.6, 0.6),
vy: 0,
angle: 0,
fuel: FUEL_MAX,
};
particles = [];
stars = makeStars();
state = "playing";
reason = "";
}
// ---- Input ----
window.addEventListener("keydown", (e) => {
Sound.resume(); // unlock audio on the first user gesture
if (e.code === "ArrowUp" || e.code === "Space") { keys.up = true; e.preventDefault(); }
else if (e.code === "ArrowLeft") { keys.left = true; e.preventDefault(); }
else if (e.code === "ArrowRight") { keys.right = true; e.preventDefault(); }
else if (e.code === "KeyR") reset();
else if (e.code === "KeyM") Sound.toggle();
});
window.addEventListener("keyup", (e) => {
if (e.code === "ArrowUp" || e.code === "Space") keys.up = false;
else if (e.code === "ArrowLeft") keys.left = false;
else if (e.code === "ArrowRight") keys.right = false;
});
// ---- Simulation step (fixed 60Hz) ----
function update() {
if (state !== "playing") return;
if (keys.left) { lander.angle -= ROT_SPEED; Sound.rcs(); }
if (keys.right) { lander.angle += ROT_SPEED; Sound.rcs(); }
const thrusting = keys.up && lander.fuel > 0;
Sound.setThrust(thrusting);
if (thrusting) {
const a = lander.angle;
lander.vx += Math.sin(a) * THRUST;
lander.vy += -Math.cos(a) * THRUST;
lander.fuel = Math.max(0, lander.fuel - BURN);
// Emit exhaust particles from the nozzle.
const nz = rotateLocal(0, 6);
const dirx = Math.sin(a), diry = -Math.cos(a); // nose direction
for (let i = 0; i < 2; i++) {
const spread = rand(-0.5, 0.5);
const sp = rand(1.2, 2.6);
particles.push({
x: nz.x,
y: nz.y,
vx: (-dirx + spread) * sp + lander.vx * 0.3,
vy: (-diry + spread) * sp + lander.vy * 0.3,
life: rand(16, 30),
max: 30,
});
}
}
lander.vy += GRAVITY;
lander.x += lander.vx;
lander.y += lander.vy;
// Bounds: bounce softly off walls, stop at ceiling.
if (lander.x < 8) { lander.x = 8; lander.vx *= -0.3; }
if (lander.x > VIEW.W - 8) { lander.x = VIEW.W - 8; lander.vx *= -0.3; }
if (lander.y < 14) { lander.y = 14; if (lander.vy < 0) lander.vy = 0; }
// Particles
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.01;
p.life -= 1;
}
particles = particles.filter((p) => p.life > 0);
// Collision: check both feet against the terrain below them.
const lf = footWorld(-1), rf = footWorld(1);
if (lf.y >= terrainYAt(lf.x) - 0.5 || rf.y >= terrainYAt(rf.x) - 0.5) {
resolve(lf, rf);
}
}
function resolve(lf, rf) {
const tilt = Math.abs(normalizeAngle(lander.angle));
const onPad =
lf.x >= terrain.padX1 && lf.x <= terrain.padX2 &&
rf.x >= terrain.padX1 && rf.x <= terrain.padX2;
const slow = Math.abs(lander.vy) <= SAFE_VY && Math.abs(lander.vx) <= SAFE_VX;
const upright = tilt <= TILT_MAX;
if (onPad && upright && slow) {
state = "landed";
reason = "The Eagle has landed.";
Sound.setThrust(false);
Sound.land();
} else {
state = "crashed";
if (!onPad) reason = "You missed the landing pad.";
else if (!upright) reason = "Touched down at a bad angle.";
else reason = "Came down too hard.";
Sound.setThrust(false);
Sound.crash();
}
}
// ---- Rendering ----
function drawBackground() {
ctx.fillStyle = "#05060a";
ctx.fillRect(0, 0, VIEW.W, VIEW.H);
for (const s of stars) {
ctx.globalAlpha = s.a;
ctx.fillStyle = "#cfd8ff";
ctx.fillRect(s.x, s.y, s.r, s.r);
}
ctx.globalAlpha = 1;
}
function drawTerrain() {
const { pts, padX1, padX2, padY } = terrain;
// Filled body
ctx.beginPath();
ctx.moveTo(0, VIEW.H);
for (const p of pts) ctx.lineTo(p.x, p.y);
ctx.lineTo(VIEW.W, VIEW.H);
ctx.closePath();
const grad = ctx.createLinearGradient(0, VIEW.H * 0.45, 0, VIEW.H);
grad.addColorStop(0, "#222a37");
grad.addColorStop(1, "#11161f");
ctx.fillStyle = grad;
ctx.fill();
// Crisp ridge line
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (const p of pts) ctx.lineTo(p.x, p.y);
ctx.strokeStyle = "#465369";
ctx.lineWidth = 2;
ctx.stroke();
// Landing pad (distinct color + markings)
ctx.strokeStyle = "#00e5a0";
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(padX1, padY);
ctx.lineTo(padX2, padY);
ctx.stroke();
// Pad end beacons
ctx.fillStyle = "#00e5a0";
for (const ex of [padX1, padX2]) {
ctx.fillRect(ex - 2, padY - 14, 4, 14);
ctx.beginPath();
ctx.arc(ex, padY - 16, 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.lineWidth = 1;
ctx.lineCap = "butt";
}
function drawParticles() {
for (const p of particles) {
const t = p.life / p.max;
ctx.globalAlpha = clamp(t, 0, 1);
ctx.fillStyle = t > 0.6 ? "#ffd24a" : t > 0.3 ? "#ff9d3a" : "#ff5a36";
const r = 2.4 * t + 0.6;
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawLander() {
ctx.save();
ctx.translate(lander.x, lander.y);
ctx.rotate(lander.angle);
// Engine flame (drawn first, behind body)
if (state === "playing" && keys.up && lander.fuel > 0) {
const flick = rand(0.7, 1.0);
const len = 16 * flick + rand(0, 5);
ctx.beginPath();
ctx.moveTo(-4, 5);
ctx.lineTo(4, 5);
ctx.lineTo(0, 5 + len);
ctx.closePath();
const fg = ctx.createLinearGradient(0, 5, 0, 5 + len);
fg.addColorStop(0, "#ffffff");
fg.addColorStop(0.3, "#ffd24a");
fg.addColorStop(1, "rgba(255,90,54,0)");
ctx.fillStyle = fg;
ctx.fill();
}
// Legs
ctx.strokeStyle = "#9fb0d0";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-5, 1); ctx.lineTo(-LEG_SPREAD, LEG_LEN);
ctx.moveTo(5, 1); ctx.lineTo(LEG_SPREAD, LEG_LEN);
ctx.stroke();
// Feet
ctx.beginPath();
ctx.moveTo(-LEG_SPREAD - 4, LEG_LEN); ctx.lineTo(-LEG_SPREAD + 4, LEG_LEN);
ctx.moveTo(LEG_SPREAD - 4, LEG_LEN); ctx.lineTo(LEG_SPREAD + 4, LEG_LEN);
ctx.stroke();
// Body
ctx.fillStyle = "#e8ecf4";
ctx.beginPath();
ctx.moveTo(-6, -6);
ctx.lineTo(6, -6);
ctx.lineTo(5, 4);
ctx.lineTo(-5, 4);
ctx.closePath();
ctx.fill();
// Nose / capsule
ctx.beginPath();
ctx.arc(0, -6, 5, Math.PI, 0);
ctx.fillStyle = "#c4cee0";
ctx.fill();
// Window
ctx.fillStyle = "#39c5ff";
ctx.beginPath();
ctx.arc(0, -5, 2, 0, Math.PI * 2);
ctx.fill();
// Nozzle
ctx.fillStyle = "#7d8aa3";
ctx.fillRect(-2.5, 4, 5, 3);
ctx.restore();
}
function drawHUD() {
const lf = footWorld(-1), rf = footWorld(1);
const groundY = Math.max(terrainYAt(lf.x), terrainYAt(rf.x));
const lowestFoot = Math.max(lf.y, rf.y);
const altitude = Math.max(0, groundY - lowestFoot);
const tiltDeg = normalizeAngle(lander.angle) * 180 / Math.PI;
const vy = lander.vy, vx = lander.vx;
// Panel
ctx.fillStyle = "rgba(10,16,26,0.72)";
ctx.fillRect(14, 14, 196, 142);
ctx.strokeStyle = "#1e2a3d";
ctx.lineWidth = 1;
ctx.strokeRect(14, 14, 196, 142);
ctx.font = "12px 'SF Mono', Menlo, Consolas, monospace";
ctx.textBaseline = "top";
const row = (label, val, ok) => {
ctx.fillStyle = "#5e6b82";
ctx.fillText(label, 24, y);
ctx.fillStyle = ok === undefined ? "#d7e0ef" : ok ? "#50fa7b" : "#ff6b6b";
ctx.fillText(val, 120, y);
y += 19;
};
let y = 24;
row("ALT", Math.round(altitude) + " m");
row("VSPD", (vy * DISPLAY_V).toFixed(1) + " m/s", Math.abs(vy) <= SAFE_VY);
row("HSPD", Math.abs(vx * DISPLAY_V).toFixed(1) + " m/s", Math.abs(vx) <= SAFE_VX);
row("ROT", tiltDeg.toFixed(0) + "°", Math.abs(normalizeAngle(lander.angle)) <= TILT_MAX);
// Fuel gauge
y += 2;
ctx.fillStyle = "#5e6b82";
ctx.fillText("FUEL " + Math.round(lander.fuel) + "%", 24, y);
y += 15;
const barX = 24, barW = 176, barH = 9;
ctx.fillStyle = "#0c1320";
ctx.fillRect(barX, y, barW, barH);
const fw = (lander.fuel / FUEL_MAX) * barW;
ctx.fillStyle = lander.fuel > 40 ? "#50fa7b" : lander.fuel > 15 ? "#ffd24a" : "#ff6b6b";
ctx.fillRect(barX, y, fw, barH);
ctx.strokeStyle = "#1e2a3d";
ctx.strokeRect(barX, y, barW, barH);
// Audio state indicator (top-right)
ctx.font = "11px 'SF Mono', Menlo, Consolas, monospace";
ctx.textAlign = "right";
ctx.fillStyle = Sound.isMuted() ? "#ff6b6b" : "#465369";
ctx.fillText((Sound.isMuted() ? "AUDIO OFF" : "AUDIO ON") + " · M", VIEW.W - 16, 22);
// Controls hint
ctx.fillStyle = "#465369";
ctx.textAlign = "center";
ctx.fillText("↑ THRUST ← → ROTATE R RESTART M MUTE", VIEW.W / 2, VIEW.H - 22);
ctx.textAlign = "left";
}
function drawOverlay() {
if (state === "playing") return;
const won = state === "landed";
ctx.fillStyle = "rgba(5,6,10,0.66)";
ctx.fillRect(0, 0, VIEW.W, VIEW.H);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 46px 'SF Mono', Menlo, Consolas, monospace";
ctx.fillStyle = won ? "#50fa7b" : "#ff6b6b";
ctx.fillText(won ? "LANDING SUCCESSFUL" : "LANDER DESTROYED", VIEW.W / 2, VIEW.H / 2 - 50);
ctx.font = "15px 'SF Mono', Menlo, Consolas, monospace";
ctx.fillStyle = "#c8d3e6";
ctx.fillText(reason, VIEW.W / 2, VIEW.H / 2 - 6);
// Final telemetry
const tiltDeg = Math.abs(normalizeAngle(lander.angle) * 180 / Math.PI);
ctx.font = "13px 'SF Mono', Menlo, Consolas, monospace";
ctx.fillStyle = "#7d8aa3";
ctx.fillText(
"VSPD " + (Math.abs(lander.vy) * DISPLAY_V).toFixed(1) + " m/s" +
" HSPD " + (Math.abs(lander.vx) * DISPLAY_V).toFixed(1) + " m/s" +
" TILT " + tiltDeg.toFixed(0) + "°",
VIEW.W / 2, VIEW.H / 2 + 28
);
ctx.font = "14px 'SF Mono', Menlo, Consolas, monospace";
ctx.fillStyle = "#d7e0ef";
ctx.fillText("Press R to restart", VIEW.W / 2, VIEW.H / 2 + 66);
ctx.textAlign = "left";
ctx.textBaseline = "top";
}
function render() {
drawBackground();
drawTerrain();
drawParticles();
drawLander();
drawHUD();
drawOverlay();
}
// ---- Main loop with fixed-timestep accumulator ----
const STEP = 1000 / 60;
let last = performance.now();
let acc = 0;
function frame(now) {
acc += now - last;
last = now;
if (acc > 200) acc = 200; // avoid spiral of death after tab switches
while (acc >= STEP) {
update();
acc -= STEP;
}
render();
requestAnimationFrame(frame);
}
reset();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lunar Lander K2.7</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #0b0d14;
display: flex; align-items: center; justify-content: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
}
#gameWrap {
position: relative;
box-shadow: 0 0 40px rgba(0,0,0,0.7);
border-radius: 8px;
overflow: hidden;
}
canvas { display: block; background: #0b0d14; }
#hud {
position: absolute; top: 0; left: 0; right: 0;
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px;
padding: 10px 14px;
font-size: 13px;
font-weight: bold;
color: #a8c6ff;
pointer-events: none;
text-shadow: 0 0 4px rgba(0,0,0,0.8);
}
#hud .label { color: #6d8cc9; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; }
#hud .value { color: #e0ecff; font-size: 15px; font-family: 'Courier New', monospace; }
#hud .value.warn { color: #ff7b72; }
#hud .value.ok { color: #7ee787; }
#fuelBarWrap {
position: absolute; bottom: 12px; left: 14px; right: 14px;
height: 10px;
background: rgba(255,255,255,0.08);
border-radius: 5px;
overflow: hidden;
pointer-events: none;
}
#fuelBar {
width: 100%; height: 100%;
background: linear-gradient(90deg, #ff7b72 0%, #ffd166 50%, #7ee787 100%);
transform-origin: left;
transition: transform 0.05s linear;
}
#overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: rgba(11,13,20,0.88);
color: #fff; text-align: center;
pointer-events: none;
}
#overlay h1 { font-size: 46px; margin-bottom: 12px; letter-spacing: 3px; }
#overlay h1.win { color: #7ee787; text-shadow: 0 0 14px #7ee787; }
#overlay h1.lose { color: #ff7b72; text-shadow: 0 0 14px #ff7b72; }
#overlay p { font-size: 16px; margin: 5px 0; color: #cdd6e6; }
#overlay .key { color: #ffd166; font-weight: bold; }
#overlay.hidden { display: none; }
</style>
</head>
<body>
<div id="gameWrap">
<canvas id="game" width="800" height="640"></canvas>
<div id="hud">
<div><div class="label">Altitude</div><div id="altVal" class="value">0</div></div>
<div><div class="label">H-Speed</div><div id="hspVal" class="value">0</div></div>
<div><div class="label">V-Speed</div><div id="vspVal" class="value">0</div></div>
<div><div class="label">Rotation</div><div id="rotVal" class="value">0°</div></div>
<div><div class="label">Fuel</div><div id="fuelVal" class="value ok">100%</div></div>
</div>
<div id="fuelBarWrap"><div id="fuelBar"></div></div>
<div id="overlay">
<h1 class="win">LUNAR LANDER K2.7</h1>
<p>Land gently on the <span class="key">green pad</span>.</p>
<p><span class="key">↑</span> thrust &nbsp; <span class="key">←</span> rotate left &nbsp; <span class="key">→</span> rotate right</p>
<p>Stay upright and slow. Press <span class="key">R</span> to restart.</p>
<p style="margin-top:18px; font-size:14px; color:#8996b0;">Press any arrow key to start</p>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const altEl = document.getElementById('altVal');
const hspEl = document.getElementById('hspVal');
const vspEl = document.getElementById('vspVal');
const rotEl = document.getElementById('rotVal');
const fuelEl = document.getElementById('fuelVal');
const fuelBar = document.getElementById('fuelBar');
const overlay = document.getElementById('overlay');
// Physics constants tuned for floaty lunar feel
const GRAVITY = 0.025;
const THRUST = 0.075;
const ROTATION_SPEED = 0.04;
const MAX_SAFE_VSPEED = 1.2;
const MAX_SAFE_HSPEED = 0.8;
const MAX_SAFE_TILT = 12 * (Math.PI / 180); // 12 degrees
const FUEL_MAX = 100;
const FUEL_BURN = 0.18;
let state = 'ready'; // ready | playing | won | crashed
let keys = {};
let stars = [];
let lastLowFuelBeep = 0;
// ---------- Audio (Web Audio API, no external assets) ----------
let audioCtx = null;
let thrusterNodes = null;
function initAudio() {
if (audioCtx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
audioCtx = new AC();
}
function resumeAudio() {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
function makeNoiseBuffer(seconds) {
if (!audioCtx) return null;
const frames = Math.floor(audioCtx.sampleRate * seconds);
const buffer = audioCtx.createBuffer(1, frames, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < frames; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
function startThruster() {
if (!audioCtx) initAudio();
if (!audioCtx || thrusterNodes) return;
const buffer = makeNoiseBuffer(2);
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
noise.loop = true;
const filter = audioCtx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 110;
filter.Q.value = 0.8;
const gain = audioCtx.createGain();
gain.gain.value = 0;
gain.gain.linearRampToValueAtTime(0.12, audioCtx.currentTime + 0.05);
noise.connect(filter);
filter.connect(gain);
gain.connect(audioCtx.destination);
noise.start();
thrusterNodes = { noise, filter, gain };
}
function stopThruster() {
if (!thrusterNodes || !audioCtx) return;
const { noise, gain } = thrusterNodes;
gain.gain.cancelScheduledValues(audioCtx.currentTime);
gain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.04);
const stopAt = audioCtx.currentTime + 0.08;
noise.stop(stopAt);
setTimeout(() => {
try { noise.disconnect(); } catch (e) {}
}, 120);
thrusterNodes = null;
}
function playTone({ freq = 440, type = 'sine', duration = 0.3, delay = 0, vol = 0.15 }) {
if (!audioCtx) initAudio();
if (!audioCtx) return;
const t = audioCtx.currentTime + delay;
const osc = audioCtx.createOscillator();
osc.type = type;
osc.frequency.value = freq;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(vol, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + duration + 0.05);
}
function playWin() {
// Ascending major arpeggio
[523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => {
playTone({ freq, type: 'sine', duration: 0.45, delay: i * 0.11, vol: 0.18 });
playTone({ freq: freq * 2, type: 'triangle', duration: 0.35, delay: i * 0.11, vol: 0.04 });
});
}
function playCrash() {
if (!audioCtx) initAudio();
if (!audioCtx) return;
const buffer = makeNoiseBuffer(1.4);
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(900, audioCtx.currentTime);
filter.frequency.exponentialRampToValueAtTime(40, audioCtx.currentTime + 1.2);
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0.4, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.005, audioCtx.currentTime + 1.2);
noise.connect(filter);
filter.connect(gain);
gain.connect(audioCtx.destination);
noise.start();
noise.stop(audioCtx.currentTime + 1.4);
// low rumble underneath
playTone({ freq: 80, type: 'sawtooth', duration: 1.0, vol: 0.12 });
}
function playLowFuel() {
const now = performance.now();
if (now - lastLowFuelBeep < 500) return;
lastLowFuelBeep = now;
playTone({ freq: 880, type: 'square', duration: 0.08, vol: 0.08 });
}
function stopAllSounds() {
stopThruster();
}
const lander = {
x: W / 2,
y: 80,
vx: 0,
vy: 0,
angle: 0, // 0 = pointing up, radians
fuel: FUEL_MAX,
width: 18,
height: 24
};
// Terrain
let terrain = [];
let pad = { x: 0, w: 90 };
const GROUND_Y = H - 60;
function rand(min, max) { return Math.random() * (max - min) + min; }
function generateStars() {
stars = [];
for (let i = 0; i < 120; i++) {
stars.push({ x: rand(0, W), y: rand(0, GROUND_Y - 30), r: rand(0.5, 2), a: rand(0.3, 1) });
}
}
function generateTerrain() {
terrain = [];
pad.w = rand(80, 120);
pad.x = rand(W * 0.25, W * 0.75 - pad.w);
const step = 10;
let y = GROUND_Y;
let x = 0;
// Left rough section up to pad
while (x < pad.x) {
terrain.push({ x, y });
x += step;
y = GROUND_Y + rand(-28, 28);
}
terrain.push({ x: pad.x, y: GROUND_Y });
// Flat landing pad
const padSteps = Math.ceil(pad.w / step);
for (let i = 0; i <= padSteps; i++) {
terrain.push({ x: pad.x + i * step, y: GROUND_Y });
}
// Right rough section
x = pad.x + pad.w;
while (x <= W) {
terrain.push({ x, y });
x += step;
y = GROUND_Y + rand(-28, 28);
}
terrain.push({ x: W, y: GROUND_Y + rand(-28, 28) });
}
function resetGame() {
stopThruster();
lander.x = W / 2;
lander.y = 80;
lander.vx = rand(-0.3, 0.3);
lander.vy = rand(-0.1, 0.2);
lander.angle = 0;
lander.fuel = FUEL_MAX;
state = 'playing';
generateTerrain();
hideOverlay();
}
function showOverlay(title, cls, line1, line2) {
overlay.innerHTML = `<h1 class="${cls}">${title}</h1><p>${line1}</p><p>${line2}</p><p style="margin-top:14px; font-size:14px; color:#8996b0;">Press <span class="key">R</span> to restart</p>`;
overlay.classList.remove('hidden');
}
function hideOverlay() {
overlay.classList.add('hidden');
}
function normalizeAngle(a) {
a = a % (Math.PI * 2);
if (a > Math.PI) a -= Math.PI * 2;
if (a < -Math.PI) a += Math.PI * 2;
return a;
}
function update() {
if (state !== 'playing') return;
// Rotation
if (keys['arrowleft'] || keys['a']) lander.angle -= ROTATION_SPEED;
if (keys['arrowright'] || keys['d']) lander.angle += ROTATION_SPEED;
lander.angle = normalizeAngle(lander.angle);
// Thrust
const thrusting = (keys['arrowup'] || keys['w']) && lander.fuel > 0;
if (thrusting) {
lander.vx += Math.sin(lander.angle) * THRUST;
lander.vy -= Math.cos(lander.angle) * THRUST;
lander.fuel = Math.max(0, lander.fuel - FUEL_BURN);
startThruster();
} else {
stopThruster();
}
// Gravity + integrate
lander.vy += GRAVITY;
lander.x += lander.vx;
lander.y += lander.vy;
// Screen wrap horizontally
if (lander.x < 0) lander.x += W;
if (lander.x > W) lander.x -= W;
// Ceiling
if (lander.y < 0) { lander.y = 0; lander.vy = Math.max(0, lander.vy); }
if (lander.fuel > 0 && lander.fuel < 10) {
playLowFuel();
}
checkLanding();
updateHUD();
}
function terrainYAt(x) {
// Wrap x into terrain range
x = ((x % W) + W) % W;
for (let i = 0; i < terrain.length - 1; i++) {
const p1 = terrain[i];
const p2 = terrain[i + 1];
if (x >= p1.x && x <= p2.x) {
const t = (x - p1.x) / (p2.x - p1.x);
return p1.y + (p2.y - p1.y) * t;
}
}
return GROUND_Y;
}
function isOverPad(x) {
const wx = ((x % W) + W) % W;
return wx >= pad.x && wx <= pad.x + pad.w;
}
function checkLanding() {
// Compute the lander's bottom points (left and right foot)
const halfW = lander.width / 2;
const cos = Math.cos(lander.angle);
const sin = Math.sin(lander.angle);
// Feet are at local (±halfW, +height/2)
const feet = [
{
x: lander.x + ( halfW * cos - lander.height/2 * sin),
y: lander.y + ( halfW * sin + lander.height/2 * cos)
},
{
x: lander.x + (-halfW * cos - lander.height/2 * sin),
y: lander.y + (-halfW * sin + lander.height/2 * cos)
}
];
let lowest = -Infinity;
let lowestX = lander.x;
for (const f of feet) {
const gy = terrainYAt(f.x);
const penetration = gy - f.y;
if (penetration < lowest) {
lowest = penetration;
lowestX = f.x;
}
if (f.y >= gy) {
// Contact
const onPad = isOverPad(f.x);
const tilt = Math.abs(lander.angle);
const speedOK = Math.abs(lander.vy) <= MAX_SAFE_VSPEED && Math.abs(lander.vx) <= MAX_SAFE_HSPEED;
const tiltOK = tilt <= MAX_SAFE_TILT;
stopThruster();
if (onPad && speedOK && tiltOK) {
state = 'won';
lander.y += penetration; // sit on ground
lander.vx = 0;
lander.vy = 0;
showOverlay('LANDED', 'win', 'Nice and gentle.', 'Mission accomplished.');
playWin();
} else {
state = 'crashed';
let reason = 'You crashed.';
if (!onPad) reason = 'That was not the landing pad.';
else if (!speedOK) reason = 'Too fast for a safe landing.';
else if (!tiltOK) reason = 'You were not upright enough.';
showOverlay('CRASHED', 'lose', reason, 'Press R to try again.');
playCrash();
}
return;
}
}
}
function drawStars() {
ctx.fillStyle = '#fff';
for (const s of stars) {
ctx.globalAlpha = s.a;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTerrain() {
// Ground body
ctx.beginPath();
ctx.moveTo(0, H);
for (const p of terrain) ctx.lineTo(p.x, p.y);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = '#161b2a';
ctx.fill();
ctx.strokeStyle = '#4a5c8a';
ctx.lineWidth = 2;
ctx.stroke();
// Landing pad
ctx.fillStyle = '#2d6a4f';
ctx.fillRect(pad.x, GROUND_Y - 4, pad.w, 6);
ctx.strokeStyle = '#7ee787';
ctx.lineWidth = 2;
ctx.strokeRect(pad.x + 2, GROUND_Y - 3, pad.w - 4, 4);
// Pad marker lights
ctx.fillStyle = '#7ee787';
ctx.beginPath();
ctx.arc(pad.x + 6, GROUND_Y - 8, 2.5, 0, Math.PI * 2);
ctx.arc(pad.x + pad.w - 6, GROUND_Y - 8, 2.5, 0, Math.PI * 2);
ctx.fill();
}
function drawLander() {
ctx.save();
ctx.translate(lander.x, lander.y);
ctx.rotate(lander.angle);
const w = lander.width;
const h = lander.height;
// Main body
ctx.fillStyle = '#e0e0e0';
ctx.beginPath();
ctx.moveTo(0, -h / 2);
ctx.lineTo(w / 2, h / 2 - 6);
ctx.lineTo(w / 2 - 4, h / 2);
ctx.lineTo(-w / 2 + 4, h / 2);
ctx.lineTo(-w / 2, h / 2 - 6);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#9aa';
ctx.lineWidth = 1.5;
ctx.stroke();
// Cockpit window
ctx.fillStyle = '#4cc9f0';
ctx.beginPath();
ctx.ellipse(0, -2, 5, 7, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
// Legs
ctx.strokeStyle = '#bbb';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-6, 8); ctx.lineTo(-10, 14); ctx.stroke();
ctx.beginPath();
ctx.moveTo(6, 8); ctx.lineTo(10, 14); ctx.stroke();
// Foot pads
ctx.fillStyle = '#888';
ctx.fillRect(-13, 14, 6, 3);
ctx.fillRect(7, 14, 6, 3);
// Thruster flame
const thrusting = (keys['arrowup'] || keys['w']) && lander.fuel > 0;
if (thrusting && state === 'playing') {
const flicker = rand(0.7, 1.3);
ctx.fillStyle = '#ff9f1c';
ctx.beginPath();
ctx.moveTo(-4, h / 2);
ctx.lineTo(4, h / 2);
ctx.lineTo(0, h / 2 + 16 * flicker);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.moveTo(-2, h / 2);
ctx.lineTo(2, h / 2);
ctx.lineTo(0, h / 2 + 8 * flicker);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawParticles() {
// Optional dust/exhaust could go here; kept simple for now
}
function draw() {
// Sky
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#0b0d14');
grad.addColorStop(1, '#1a1f35');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
drawStars();
drawTerrain();
drawLander();
drawParticles();
}
function formatSpeed(v) {
return (v >= 0 ? '+' : '') + v.toFixed(2);
}
function updateHUD() {
const groundH = terrainYAt(lander.x);
const altitude = Math.max(0, groundH - (lander.y + lander.height / 2));
altEl.textContent = altitude.toFixed(1);
hspEl.textContent = formatSpeed(lander.vx);
vspEl.textContent = formatSpeed(lander.vy);
const deg = (lander.angle * 180 / Math.PI).toFixed(1);
rotEl.textContent = deg + '°';
const fuelPct = (lander.fuel / FUEL_MAX * 100).toFixed(0);
fuelEl.textContent = fuelPct + '%';
fuelBar.style.transform = `scaleX(${lander.fuel / FUEL_MAX})`;
// Color HUD values based on safety
vspEl.className = 'value' + (Math.abs(lander.vy) > MAX_SAFE_VSPEED ? ' warn' : ' ok');
hspEl.className = 'value' + (Math.abs(lander.vx) > MAX_SAFE_HSPEED ? ' warn' : ' ok');
fuelEl.className = 'value' + (lander.fuel < 20 ? ' warn' : ' ok');
}
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
window.addEventListener('keydown', e => {
initAudio();
resumeAudio();
const key = e.key.toLowerCase();
if (key === 'arrowup' || key === 'arrowdown' || key === 'arrowleft' || key === 'arrowright' ||
key === 'w' || key === 'a' || key === 's' || key === 'd') {
e.preventDefault();
}
keys[key] = true;
if (key === 'r') {
resetGame();
return;
}
if (state === 'ready' && ['arrowup', 'arrowleft', 'arrowright', 'w', 'a', 'd'].includes(key)) {
resetGame();
}
});
window.addEventListener('keyup', e => {
keys[e.key.toLowerCase()] = false;
});
// Touch controls
let touchStartX = 0, touchStartY = 0;
canvas.addEventListener('touchstart', e => {
initAudio();
resumeAudio();
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
if (state === 'ready') resetGame();
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
keys['arrowup'] = false;
keys['arrowleft'] = false;
keys['arrowright'] = false;
const dx = e.touches[0].clientX - touchStartX;
const dy = e.touches[0].clientY - touchStartY;
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) {
keys['arrowup'] = true;
} else if (Math.abs(dy) > Math.abs(dx) && dy < 0) {
keys['arrowup'] = true;
} else if (dx < -20) {
keys['arrowleft'] = true;
} else if (dx > 20) {
keys['arrowright'] = true;
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
keys['arrowup'] = false;
keys['arrowleft'] = false;
keys['arrowright'] = false;
}, { passive: false });
generateStars();
generateTerrain();
updateHUD();
loop();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Lunar Lander M3</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #05060a;
color: #e8eef7;
font-family: "Menlo", "Consolas", "Courier New", monospace;
overflow: hidden;
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
h1 {
margin: 10px 0 6px 0;
font-size: 16px;
letter-spacing: 4px;
color: #aab7c6;
font-weight: normal;
}
canvas {
background: radial-gradient(ellipse at top, #0b1024 0%, #05060a 70%);
box-shadow: 0 0 30px rgba(80, 120, 200, 0.15);
border: 1px solid #1b2336;
}
.hud {
display: flex;
gap: 18px;
padding: 8px 12px;
font-size: 12px;
color: #9fb0c5;
}
.hud span b {
color: #e8eef7;
font-weight: normal;
margin-left: 4px;
}
.hud .ok { color: #6ee7a0; }
.hud .warn { color: #ffcc66; }
.hud .bad { color: #ff6b6b; }
.legend {
font-size: 11px;
color: #6f7d92;
padding: 4px 0 10px 0;
}
.legend kbd {
background: #11151f;
border: 1px solid #2a334a;
padding: 1px 5px;
border-radius: 3px;
color: #cfd8e3;
}
</style>
</head>
<body>
<div class="wrap">
<h1>LUNAR LANDER</h1>
<div class="hud">
<span>ALT <b id="hud-alt" class="ok">000</b></span>
<span>VX <b id="hud-vx">0.0</b></span>
<span>VY <b id="hud-vy">0.0</b></span>
<span>FUEL <b id="hud-fuel" class="ok">100</b></span>
<span>ROT <b id="hud-rot">0&deg;</b></span>
<span>STATUS <b id="hud-status" class="ok">FLYING</b></span>
</div>
<canvas id="game" width="900" height="560"></canvas>
<div class="legend">
<kbd>&uarr;</kbd> main thrust &nbsp;&middot;&nbsp;
<kbd>&larr;</kbd> <kbd>&rarr;</kbd> rotate &nbsp;&middot;&nbsp;
<kbd>R</kbd> restart &nbsp;&middot;&nbsp;
<kbd>M</kbd> mute
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
// ---- Game tuning (lunar / floaty) --------------------------------------
const GRAVITY = 0.04; // low lunar pull (pixels/frame^2)
const MAIN_THRUST = 0.09; // accel up along lander facing
const SIDE_THRUST = 0.05; // weak RCS for left/right rotation feel
const ROT_SPEED = 0.05; // radians / frame
const FUEL_BURN_MAIN = 0.25; // per frame of main thrust
const FUEL_BURN_RCS = 0.05; // per frame of rotation
const SAFE_VY = 1.8; // |vy| threshold at touchdown
const SAFE_VX = 1.4; // |vx| threshold at touchdown
const SAFE_TILT = 0.25; // |angle| radians (~14 deg)
const START_FUEL = 100;
// ---- State -------------------------------------------------------------
let lander, particles, terrain, pad, stars, status, message, messageColor;
function reset() {
stopThrustSound();
lander = {
x: W * 0.5,
y: 80,
vx: (Math.random() - 0.5) * 0.4,
vy: 0,
angle: 0, // 0 = pointing up; +ve = clockwise
w: 22, h: 26, // half-width/height for collision
fuel: START_FUEL,
thrusting: false,
flameScale: 0,
};
particles = [];
status = 'flying'; // flying | win | crash
message = '';
messageColor = '#e8eef7';
// jagged terrain
terrain = [];
const baseY = H - 60;
let x = 0;
const step = 22;
while (x < W) {
// height noise: bias low with occasional peaks
const n = Math.random();
const h = 10 + n * n * 70;
terrain.push({ x: x, y: baseY - h });
x += step;
}
terrain.push({ x: W, y: baseY });
// close polygon to the bottom
terrain.push({ x: W, y: H });
terrain.push({ x: 0, y: H });
// pick a flat pad
const padW = 90;
const padStart = 120 + Math.random() * (W - 240);
const padEnd = padStart + padW;
// flatten the segment: average neighbors, force same y
const yVals = [];
for (let i = 0; i < terrain.length; i++) {
const t = terrain[i];
if (t.x >= padStart && t.x <= padEnd) yVals.push(t.y);
}
const padY = yVals.reduce((a,b)=>a+b,0) / Math.max(1, yVals.length);
for (let i = 0; i < terrain.length; i++) {
const t = terrain[i];
if (t.x >= padStart && t.x <= padEnd) t.y = padY;
}
pad = { x1: padStart, x2: padEnd, y: padY, w: padW };
// stars
stars = [];
for (let i = 0; i < 110; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * (H * 0.6),
r: Math.random() * 1.1 + 0.2,
tw: Math.random() * Math.PI * 2,
});
}
}
// ---- Audio (Web Audio API, all synthesized) ----------------------------
let audioCtx = null;
let masterGain = null;
let thrustNode = null; // persistent low-noise loop while thrusting
let muted = false;
function ensureAudio() {
if (audioCtx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
audioCtx = new AC();
masterGain = audioCtx.createGain();
masterGain.gain.value = muted ? 0 : 0.6;
masterGain.connect(audioCtx.destination);
}
function startThrustSound() {
if (!audioCtx || thrustNode) return;
// Build a noise buffer once
const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 1.0, audioCtx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * 0.8;
const noise = audioCtx.createBufferSource();
noise.buffer = buf;
noise.loop = true;
// Bandpass for that rocket rumble
const bp = audioCtx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 180;
bp.Q.value = 0.7;
// A sub sine to give body
const sub = audioCtx.createOscillator();
sub.type = 'sine';
sub.frequency.value = 70;
const g = audioCtx.createGain();
g.gain.value = 0.0;
g.gain.setTargetAtTime(0.35, audioCtx.currentTime, 0.05);
const subGain = audioCtx.createGain();
subGain.gain.value = 0.25;
noise.connect(bp).connect(g);
sub.connect(subGain).connect(g);
g.connect(masterGain);
noise.start();
sub.start();
thrustNode = { noise, sub, gain: g, subGain, bp };
}
function stopThrustSound() {
if (!audioCtx || !thrustNode) return;
const { noise, sub, gain, subGain } = thrustNode;
const t = audioCtx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setTargetAtTime(0, t, 0.08);
setTimeout(() => {
try { noise.stop(); sub.stop(); } catch(e) {}
try { gain.disconnect(); subGain.disconnect(); } catch(e) {}
}, 250);
thrustNode = null;
}
function blip(freq, dur, type = 'square', vol = 0.15, slide = 0) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur);
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(masterGain);
o.start(t);
o.stop(t + dur + 0.02);
}
function playRcs() {
if (!audioCtx) return;
blip(520, 0.06, 'square', 0.06, -120);
}
function playCrash() {
if (!audioCtx) return;
// Big noise burst
const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.6, audioCtx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length);
const n = audioCtx.createBufferSource();
n.buffer = buf;
const bp = audioCtx.createBiquadFilter();
bp.type = 'lowpass';
bp.frequency.setValueAtTime(1500, audioCtx.currentTime);
bp.frequency.exponentialRampToValueAtTime(120, audioCtx.currentTime + 0.6);
const g = audioCtx.createGain();
g.gain.value = 0.6;
n.connect(bp).connect(g).connect(masterGain);
n.start();
// Sub thump
blip(90, 0.4, 'sine', 0.5, -40);
// Sparkle crunches
setTimeout(() => blip(220, 0.08, 'sawtooth', 0.15, -80), 80);
setTimeout(() => blip(160, 0.12, 'sawtooth', 0.12, -60), 180);
}
function playWin() {
if (!audioCtx) return;
// Triumphant arpeggio
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5 E5 G5 C6
notes.forEach((f, i) => {
setTimeout(() => blip(f, 0.22, 'triangle', 0.18), i * 110);
});
// Soft pad swell
setTimeout(() => {
const t = audioCtx.currentTime;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'sine';
o.frequency.value = 261.63;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.08, t + 0.2);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.4);
o.connect(g).connect(masterGain);
o.start(t); o.stop(t + 1.5);
}, 350);
}
function playClick() {
if (!audioCtx) return;
blip(900, 0.04, 'square', 0.08);
}
function setMuted(m) {
muted = m;
if (masterGain) masterGain.gain.value = muted ? 0 : 0.6;
if (muted) stopThrustSound();
}
// ---- Input -------------------------------------------------------------
const keys = {};
window.addEventListener('keydown', (e) => {
ensureAudio();
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
keys[e.key] = true;
if (e.key === 'r' || e.key === 'R') { playClick(); reset(); }
if (e.key === 'm' || e.key === 'M') { setMuted(!muted); }
if (['ArrowUp','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
}, { passive: false });
window.addEventListener('keyup', (e) => { keys[e.key] = false; });
// ---- Helpers -----------------------------------------------------------
function rotate(px, py, a) {
const c = Math.cos(a), s = Math.sin(a);
return { x: px * c - py * s, y: px * s + py * c };
}
function landerCorners() {
// rectangle local coords, centered at (0,0); up is -y
const w = lander.w, h = lander.h;
const pts = [
{ x: -w/2, y: -h/2 },
{ x: w/2, y: -h/2 },
{ x: w/2, y: h/2 },
{ x: -w/2, y: h/2 },
];
return pts.map(p => {
const r = rotate(p.x, p.y, lander.angle);
return { x: lander.x + r.x, y: lander.y + r.y };
});
}
function segmentIntersect(p1, p2, p3, p4) {
const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x);
if (Math.abs(d) < 1e-9) return null;
const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d;
const u = -((p1.x - p2.x) * (p1.y - p3.y) - (p1.y - p2.y) * (p1.x - p3.x)) / d;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) };
}
return null;
}
function terrainYAt(x) {
// linear interpolation along terrain top edge
for (let i = 0; i < terrain.length - 1; i++) {
const a = terrain[i], b = terrain[i+1];
if (x >= a.x && x <= b.x) {
const t = (x - a.x) / Math.max(1, (b.x - a.x));
return { y: a.y + (b.y - a.y) * t, seg: i, a, b };
}
}
return null;
}
function isOnPad(x) {
return x >= pad.x1 && x <= pad.x2;
}
// ---- Physics -----------------------------------------------------------
function update() {
if (status !== 'flying') return;
const thrusting = keys['ArrowUp'] && lander.fuel > 0;
const turningL = keys['ArrowLeft'];
const turningR = keys['ArrowRight'];
// rotation
if (turningL) { lander.angle -= ROT_SPEED; playRcs(); }
if (turningR) { lander.angle += ROT_SPEED; playRcs(); }
// small visual wobble when out of fuel
if (lander.fuel <= 0 && (turningL || turningR)) {
lander.angle += (Math.random() - 0.5) * 0.01;
}
// thrust sound start/stop
if (thrusting) startThrustSound();
else stopThrustSound();
// gravity
lander.vy += GRAVITY;
// thrust along facing
if (thrusting) {
// up along lander: facing dir is -y in local; in world, thrust vector points opposite of "up local"?
// Our lander is drawn pointing up (angle 0 -> -y). Thrust pushes along facing.
// Use the up vector: (sin a, -cos a) -> that gives the direction the nose points.
// We want thrust to push the lander in the direction the nose points? No — engines fire downward from the lander, so thrust vector points UP from the lander bottom, which is along the nose direction (opposite of falling).
// Convention here: angle 0, nose up -> thrust vector is (0, -1) (up on screen).
// After rotation by angle a, the local up (0,-1) becomes (sin a, -cos a).
const ax = Math.sin(lander.angle);
const ay = -Math.cos(lander.angle);
lander.vx += ax * MAIN_THRUST;
lander.vy += ay * MAIN_THRUST;
lander.fuel = Math.max(0, lander.fuel - FUEL_BURN_MAIN);
// exhaust particles
const exhaustDir = -1; // opposite of thrust
const ex = lander.x - ax * (lander.h * 0.4);
const ey = lander.y - ay * (lander.h * 0.4);
for (let i = 0; i < 3; i++) {
const spread = (Math.random() - 0.5) * 0.6;
const speed = 1.5 + Math.random() * 1.5;
particles.push({
x: ex, y: ey,
vx: -ax * speed + Math.cos(lander.angle + Math.PI/2) * spread * speed,
vy: -ay * speed + Math.sin(lander.angle + Math.PI/2) * spread * speed,
life: 30 + Math.random() * 15,
max: 45,
hot: true,
});
}
lander.flameScale = 1.0;
} else {
lander.flameScale *= 0.85;
}
if (turningL || turningR) {
lander.fuel = Math.max(0, lander.fuel - FUEL_BURN_RCS);
}
// tiny drag for floaty feel
lander.vx *= 0.999;
// integrate
lander.x += lander.vx;
lander.y += lander.vy;
// walls
if (lander.x < 8) { lander.x = 8; lander.vx = Math.abs(lander.vx) * 0.4; }
if (lander.x > W-8){ lander.x = W-8;lander.vx = -Math.abs(lander.vx) * 0.4; }
if (lander.y < 8) { lander.y = 8; lander.vy = Math.abs(lander.vy) * 0.4; }
// collision against terrain top edge (only the visible top; terrain includes the
// polygon down to bottom corners but segIntersect below handles that fine)
const corners = landerCorners();
for (let i = 0; i < terrain.length - 1; i++) {
const a = terrain[i], b = terrain[i+1];
// skip the "down to bottom" segments at the ends so we don't trigger on the canvas floor
if (a.y >= H - 1 || b.y >= H - 1) continue;
for (let j = 0; j < 4; j++) {
const c1 = corners[j];
const c2 = corners[(j+1) % 4];
const hit = segmentIntersect(c1, c2, a, b);
if (hit) {
onCollision(i, hit);
return;
}
}
}
// particles
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.02; // small gravity for exhaust smoke
p.life -= 1;
}
particles = particles.filter(p => p.life > 0);
}
function onCollision(segIndex, hitPoint) {
const onPadX = hitPoint.x >= pad.x1 && hitPoint.x <= pad.x2;
const tiltOk = Math.abs(lander.angle) < SAFE_TILT ||
Math.abs(Math.abs(lander.angle) - Math.PI) < SAFE_TILT;
const vyOk = Math.abs(lander.vy) < SAFE_VY;
const vxOk = Math.abs(lander.vx) < SAFE_VX;
if (onPadX && tiltOk && vyOk && vxOk) {
status = 'win';
message = 'TOUCHDOWN!';
messageColor = '#6ee7a0';
stopThrustSound();
playWin();
} else {
status = 'crash';
// friendly reason
let reason = 'CRASHED';
if (!onPadX) reason = 'OFF PAD';
else if (!tiltOk) reason = 'BAD ANGLE';
else if (!vyOk) reason = 'TOO FAST (V)';
else if (!vxOk) reason = 'TOO FAST (H)';
message = reason;
messageColor = '#ff6b6b';
stopThrustSound();
playCrash();
// explosion
for (let i = 0; i < 60; i++) {
const a = Math.random() * Math.PI * 2;
const s = 1 + Math.random() * 3.5;
particles.push({
x: hitPoint.x, y: hitPoint.y,
vx: Math.cos(a) * s,
vy: Math.sin(a) * s,
life: 40 + Math.random() * 30,
max: 70,
hot: true,
});
}
}
}
// ---- Drawing -----------------------------------------------------------
function drawStars() {
for (const s of stars) {
s.tw += 0.04;
const a = 0.5 + 0.5 * Math.sin(s.tw);
ctx.fillStyle = `rgba(220, 230, 255, ${0.25 + 0.55 * a})`;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawTerrain() {
// pad base glow
ctx.fillStyle = 'rgba(110, 231, 160, 0.18)';
ctx.fillRect(pad.x1 - 4, pad.y - 4, pad.w + 8, 4);
// terrain fill
ctx.beginPath();
ctx.moveTo(terrain[0].x, terrain[0].y);
for (let i = 1; i < terrain.length; i++) ctx.lineTo(terrain[i].x, terrain[i].y);
ctx.closePath();
ctx.fillStyle = '#101521';
ctx.fill();
// terrain outline
ctx.beginPath();
ctx.moveTo(terrain[0].x, terrain[0].y);
for (let i = 1; i < terrain.length; i++) ctx.lineTo(terrain[i].x, terrain[i].y);
ctx.strokeStyle = '#3a4566';
ctx.lineWidth = 1.4;
ctx.stroke();
// pad marker
ctx.strokeStyle = '#6ee7a0';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pad.x1, pad.y);
ctx.lineTo(pad.x2, pad.y);
ctx.stroke();
// pad flags
function flag(x) {
ctx.strokeStyle = '#6ee7a0';
ctx.beginPath();
ctx.moveTo(x, pad.y);
ctx.lineTo(x, pad.y - 14);
ctx.stroke();
ctx.fillStyle = '#6ee7a0';
ctx.beginPath();
ctx.moveTo(x, pad.y - 14);
ctx.lineTo(x + 8, pad.y - 11);
ctx.lineTo(x, pad.y - 8);
ctx.closePath();
ctx.fill();
}
flag(pad.x1);
flag(pad.x2);
}
function drawLander() {
ctx.save();
ctx.translate(lander.x, lander.y);
ctx.rotate(lander.angle);
// body
ctx.fillStyle = '#dbe2ee';
ctx.strokeStyle = '#6a7793';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(0, -lander.h/2);
ctx.lineTo(lander.w/2, -lander.h/4);
ctx.lineTo(lander.w/2, lander.h/2);
ctx.lineTo(-lander.w/2, lander.h/2);
ctx.lineTo(-lander.w/2, -lander.h/4);
ctx.closePath();
ctx.fill();
ctx.stroke();
// window
ctx.fillStyle = '#6ee7a0';
ctx.beginPath();
ctx.arc(0, -lander.h/6, 3, 0, Math.PI * 2);
ctx.fill();
// legs
ctx.strokeStyle = '#9aa6bd';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(-lander.w/2 + 2, lander.h/2);
ctx.lineTo(-lander.w/2 - 6, lander.h/2 + 8);
ctx.moveTo(lander.w/2 - 2, lander.h/2);
ctx.lineTo(lander.w/2 + 6, lander.h/2 + 8);
ctx.stroke();
// flame
if (lander.flameScale > 0.05 && lander.fuel > 0) {
const f = lander.flameScale;
const grad = ctx.createLinearGradient(0, lander.h/2, 0, lander.h/2 + 30 * f);
grad.addColorStop(0, 'rgba(255, 220, 120, 0.95)');
grad.addColorStop(0.5, 'rgba(255, 120, 60, 0.6)');
grad.addColorStop(1, 'rgba(120, 60, 30, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(-6, lander.h/2);
ctx.quadraticCurveTo(0, lander.h/2 + 26 * f, 6, lander.h/2);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawParticles() {
for (const p of particles) {
const a = Math.max(0, p.life / p.max);
const r = p.hot ? 255 : 180;
const g = p.hot ? 200 : 180;
const b = p.hot ? 90 : 200;
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
ctx.beginPath();
ctx.arc(p.x, p.y, 1.4 + a * 1.2, 0, Math.PI * 2);
ctx.fill();
}
}
function drawThrustVector() {
// small arrow showing thrust direction (only when thrusting)
if (!(keys['ArrowUp'] && lander.fuel > 0 && status === 'flying')) return;
const ax = Math.sin(lander.angle);
const ay = -Math.cos(lander.angle);
const sx = lander.x + ax * 36;
const sy = lander.y + ay * 36;
ctx.strokeStyle = 'rgba(110, 231, 160, 0.85)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(lander.x, lander.y);
ctx.lineTo(sx, sy);
ctx.stroke();
// arrow head
const head = 6;
const ang = Math.atan2(ay, ax);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx - head * Math.cos(ang - 0.4), sy - head * Math.sin(ang - 0.4));
ctx.lineTo(sx - head * Math.cos(ang + 0.4), sy - head * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fillStyle = 'rgba(110, 231, 160, 0.85)';
ctx.fill();
}
function drawHUDOverlay() {
// fuel bar at top-right
const barW = 140, barH = 8;
const x = W - barW - 16, y = 16;
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(x, y, barW, barH);
const f = lander.fuel / START_FUEL;
const col = f > 0.4 ? '#6ee7a0' : (f > 0.15 ? '#ffcc66' : '#ff6b6b');
ctx.fillStyle = col;
ctx.fillRect(x, y, barW * f, barH);
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.strokeRect(x, y, barW, barH);
ctx.fillStyle = '#9fb0c5';
ctx.font = '10px Menlo, monospace';
ctx.textAlign = 'right';
ctx.fillText('FUEL', x - 6, y + 8);
// velocity vector mini-HUD near lander
if (status === 'flying') {
const speed = Math.hypot(lander.vx, lander.vy);
const col = speed < 2 ? '#6ee7a0' : (speed < 4 ? '#ffcc66' : '#ff6b6b');
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc(lander.x, lander.y - 30, 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
function drawMessage() {
if (!message) return;
ctx.fillStyle = 'rgba(5, 6, 10, 0.65)';
ctx.fillRect(0, H/2 - 40, W, 80);
ctx.fillStyle = messageColor;
ctx.font = 'bold 28px Menlo, monospace';
ctx.textAlign = 'center';
ctx.fillText(message, W/2, H/2);
ctx.fillStyle = '#9fb0c5';
ctx.font = '12px Menlo, monospace';
ctx.fillText('Press R to restart', W/2, H/2 + 26);
}
function updateHUD() {
const alt = Math.max(0, Math.round(terrainYAt(lander.x)?.y - lander.y || 0));
const altEl = document.getElementById('hud-alt');
altEl.textContent = String(alt).padStart(3, '0');
altEl.className = alt > 80 ? 'ok' : (alt > 30 ? 'warn' : 'bad');
document.getElementById('hud-vx').textContent = lander.vx.toFixed(1);
document.getElementById('hud-vy').textContent = lander.vy.toFixed(1);
const fuelEl = document.getElementById('hud-fuel');
fuelEl.textContent = Math.round(lander.fuel);
fuelEl.className = lander.fuel > 40 ? 'ok' : (lander.fuel > 15 ? 'warn' : 'bad');
let deg = Math.round(lander.angle * 180 / Math.PI);
// normalize to -180..180
deg = ((deg + 180) % 360 + 360) % 360 - 180;
document.getElementById('hud-rot').textContent = `${deg}\u00B0`;
const stEl = document.getElementById('hud-status');
stEl.textContent = status.toUpperCase();
stEl.className = status === 'flying' ? 'ok' : (status === 'win' ? 'ok' : 'bad');
}
function draw() {
ctx.clearRect(0, 0, W, H);
drawStars();
drawTerrain();
drawParticles();
drawThrustVector();
drawLander();
drawHUDOverlay();
drawMessage();
}
function loop() {
update();
draw();
updateHUD();
requestAnimationFrame(loop);
}
reset();
loop();
})();
</script>
</body>
</html>
Build a basic Lunar Lander game as a single HTML file (HTML + CSS + JavaScript on a <canvas>).
Mechanics:
A small lander starts near the top of the screen and is pulled downward by constant gravity.
The player presses the up arrow to fire the main thruster (counteracts gravity) and left/right arrows to rotate the lander, applying thrust in the direction it's pointing.
Fuel is limited and decreases while thrusting; show a fuel gauge. When fuel runs out, thrust no longer works.
Generate a jagged terrain along the bottom with one flat landing pad marked in a different color.
Win if the lander touches the pad while upright (small tilt tolerance) and below a safe descent speed. Crash otherwise (too fast, too tilted, or hitting non-pad terrain).
Display live readouts: altitude, horizontal speed, vertical speed, fuel, and rotation.
Show a win/lose message and a "Press R to restart" option.
Keep it self-contained with no external libraries, use simple vector-style graphics, and make the physics feel slightly floaty to match low lunar gravity.
Add Sounds
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment