My own small flight sim in the spirit of Microsoft Flight Simulator 98, drawn in real 3D with the browser's built-in WebGL, so there's nothing to install. Play it at https://games.503mail.com/skypilot/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.
What's in it
It takes place on an 8 km island with flat-shaded hills, patchwork farm fields, forest, snowy peaks, beaches, a town, clouds, and two airfields with hangars and towers. The plane is a high-wing trainer. The flight model is simple, but it behaves like a plane: throttle and drag set the speed, climbing costs speed, banking turns you, and flying too slowly makes you stall and sink. There are two views: behind the plane, or a cockpit with an instrument panel of airspeed, artificial horizon, altimeter and compass. Free flight lets you go anywhere. Ring race has you fly through 10 rings round the island, with an arrow pointing to the next one, and then land on the main runway, timed. Landings are rated smooth, good or bumpy. Touching down too fast or too steep, hitting a building, or landing in the sea is a crash, and you start again on the runway. Easy (on by default on touch screens) makes the plane level itself when you let go and stops it from stalling. Up = climb swaps the stick for kids. Real pilots pull back to climb.
Controls
Keyboard: ◀ ▶ bank, ▼ nose up, ▲ nose down, Q / Z or 1 to 0 throttle, B brakes, V view, R back to the runway, P pause, M sound. Touch: a stick on the left, a GAS slider on the right, and BRAKE, VIEW and pause buttons.
Rebuild, step by step
Sky Pilot is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Sky Pilot tile.
1. Make the game's folder
Proxmox host shell (Proxmox web UI → pve → Shell):
mkdir -p /root/nibbles/skypilot
2. Create the game file
Paste this into the Proxmox host shell. It writes /root/nibbles/skypilot/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):
cat > /root/nibbles/skypilot/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Sky Pilot</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="Sky Pilot">
<meta property="og:image" content="https://games.503mail.com/skypilot/icon-512.png">
<style>
html,body{margin:0;height:100%;background:#9cc4ec;overflow:hidden;font-family:"Lucida Console","Courier New",monospace}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
#setup{position:fixed;inset:0;z-index:5;background:radial-gradient(rgba(20,40,90,.88),rgba(0,0,0,.94));color:#fff;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2.2vmin;font-size:clamp(12px,2.6vmin,22px);text-align:center;padding:16px;box-sizing:border-box}
#setup.hidden{display:none}
#setup h1{margin:0;color:#FFFF55;letter-spacing:.3em;font-size:1.8em}
#setup .sub{color:#55FFFF;letter-spacing:.3em;margin-top:-1.2vmin}
#setup .row{display:flex;gap:1em;align-items:center;flex-wrap:wrap;justify-content:center}
#setup button{font:inherit;cursor:pointer;border:0}
#setup #modes button{background:#0000AA;color:#fff;border:3px solid #555;padding:.4em 1em}
#setup #modes button.on{border-color:#FFFF55;color:#FFFF55}
#setup #modes small{color:#AAAAAA}
#setup #go{background:#FF5555;color:#fff;font-size:1.2em;padding:.4em 2em}
#setup .help{color:#AAAAAA;font-size:.85em;line-height:1.6}
#setup kbd{color:#FFFF55}
#setup a{color:#55FFFF}
#nogl{display:none;color:#FF5555}
/* touch controls float over the picture: stick bottom left, throttle bottom right, buttons top right */
.t{display:none;position:fixed;z-index:3;touch-action:none;user-select:none;-webkit-user-select:none}
body.touch .t{display:block}
#stick{left:3vmin;bottom:3vmin;width:34vmin;height:34vmin;border-radius:50%;background:rgba(0,0,80,.35);border:3px solid rgba(85,255,255,.7)}
#knob{position:absolute;left:50%;top:50%;width:12vmin;height:12vmin;margin:-6vmin 0 0 -6vmin;border-radius:50%;background:rgba(255,255,85,.85);border:2px solid #000}
#thr{right:3vmin;bottom:3vmin;width:13vmin;height:40vmin;border-radius:3vmin;background:rgba(0,0,80,.35);border:3px solid rgba(85,255,255,.7)}
#thrfill{position:absolute;left:0;right:0;bottom:0;border-radius:0 0 2.5vmin 2.5vmin;background:rgba(85,255,85,.55)}
#thrlab{position:absolute;left:0;right:0;top:-5vmin;text-align:center;color:#fff;font-size:2.6vmin;text-shadow:1px 1px #000}
#btns{right:2vmin;top:2vmin;display:none;gap:1.5vmin}
body.touch #btns{display:flex}
#btns button{font:bold 3vmin "Lucida Console","Courier New",monospace;background:#0000AA;color:#FFFF55;border:3px solid #55FFFF;border-radius:1.5vmin;padding:1.2vmin 2vmin;touch-action:none}
#btns button.held{background:#5555FF}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="hud"></canvas>
<div id="stick" class="t" aria-label="Control stick"><div id="knob"></div></div>
<div id="thr" class="t" aria-label="Throttle"><div id="thrfill"></div><div id="thrlab">GAS</div></div>
<div id="btns" class="t"><button data-a="brake">BRAKE</button><button data-a="view">VIEW</button><button data-a="pause">II</button></div>
<div id="setup">
<h1>SKY PILOT</h1>
<div class="sub">FLY · RINGS · LAND</div>
<div class="row" id="modes">
<button data-m="free">FREE FLIGHT<br><small>go anywhere</small></button>
<button data-m="rings">RING RACE<br><small>10 rings, then land</small></button>
</div>
<div class="row"><label><input type="checkbox" id="snd" checked> Sound</label>
<label><input type="checkbox" id="easy"> Easy</label>
<label><input type="checkbox" id="upc"> Up = climb</label></div>
<button id="go">FLY!</button>
<div id="hi"></div>
<div id="nogl">This browser can't draw 3D (WebGL is off), so the game can't run here.</div>
<div class="help">
<kbd>← →</kbd> bank · <kbd>↓</kbd> nose up · <kbd>↑</kbd> nose down · <kbd>Q</kbd>/<kbd>Z</kbd> or <kbd>1</kbd>–<kbd>0</kbd> throttle<br>
<kbd>B</kbd> brakes · <kbd>V</kbd> cockpit / outside view · <kbd>R</kbd> back to the runway · <kbd>P</kbd> pause · <kbd>M</kbd> sound<br>
Take off: full throttle, and at 50 knots pull the nose up.<br>
Land: slow down, line up with a runway and touch down gently.
</div>
<a href="/">Other games</a>
</div>
<script>
(() => {
const cv = document.getElementById('gl'), hud = document.getElementById('hud'), h2 = hud.getContext('2d');
const setupEl = document.getElementById('setup');
const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
if (isTouch) document.body.classList.add('touch');
const clamp = (v, a, b) => Math.max(a, Math.min(b, v)), lerp = (a, b, p) => a + (b - a) * p;
// ---- settings + best time (per-viewer convenience only) ----
let cfg = { sound:true, easy:isTouch, upClimb:isTouch, mode:'free', best:0 };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('skypilot') || '{}')); } catch (e) {}
const saveCfg = () => { try { localStorage.setItem('skypilot', JSON.stringify(cfg)); } catch (e) {} };
// ---- sound ----
let actx = null, noiseBuf = null, eng = null;
function audio() {
if (!cfg.sound) return null;
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
if (actx.state === 'suspended') actx.resume();
return actx;
}
function sweep(f0, f1, dur, type = 'square', vol = 0.04) {
const a = audio(); if (!a) return;
const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
o.type = type; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0005, t + dur);
o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
}
function boom(dur = 1.2, vol = 0.35, cutoff = 700) {
const a = audio(); if (!a) return;
if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
const t = a.currentTime, s = a.createBufferSource(), f = a.createBiquadFilter(), v = a.createGain();
s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(cutoff, t); f.frequency.exponentialRampToValueAtTime(40, t + dur);
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.001, t + dur);
s.connect(f).connect(v).connect(a.destination); s.start(t); s.stop(t + dur);
}
const notes = (fs, gap, dur, vol = 0.04) => fs.forEach((f, i) => setTimeout(() => sweep(f, f, dur, 'square', vol), i * gap));
const SFX = {
ring: () => notes([784, 1047, 1319], 70, 0.1),
land: () => notes([523, 659, 784], 110, 0.12),
win: () => notes([523, 659, 784, 1047, 784, 1047], 150, 0.14, 0.05),
stall: () => sweep(1100, 1100, 0.12, 'square', 0.03),
crash: () => boom(),
tyre: () => sweep(300, 120, 0.15, 'sawtooth', 0.03),
};
// propeller engine: pitch follows throttle and airspeed
function engineSet(on, thr, spd) {
if (!eng) {
if (!on) return;
const a = audio(); if (!a) return;
const o1 = a.createOscillator(), o2 = a.createOscillator(), f = a.createBiquadFilter(), v = a.createGain();
o1.type = 'sawtooth'; o2.type = 'square'; f.type = 'lowpass'; f.frequency.value = 500; v.gain.value = 0;
o1.connect(f); o2.connect(f); f.connect(v).connect(a.destination); o1.start(); o2.start();
eng = { o1, o2, v, a };
}
const t = eng.a.currentTime, fr = 38 + thr * 62 + spd * .25;
eng.v.gain.setTargetAtTime(on && cfg.sound ? 0.02 + thr * 0.025 : 0, t, 0.1);
eng.o1.frequency.setTargetAtTime(fr, t, 0.1); eng.o2.frequency.setTargetAtTime(fr * 1.5, t, 0.1);
}
// ---- small vector helpers ([x, y, z] arrays) ----
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]], sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const mul = (a, s) => [a[0] * s, a[1] * s, a[2] * s], dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const len = a => Math.hypot(a[0], a[1], a[2]), norm = a => { const l = len(a) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
function perspective(fy, asp, n, f) { const t = 1 / Math.tan(fy / 2), nf = 1 / (n - f); return new Float32Array([t / asp, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) * nf, -1, 0, 0, 2 * f * n * nf, 0]); }
function lookAt(e, c, up) {
const z = norm(sub(e, c)), x = norm(cross(up, z)), y = cross(z, x);
return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, e), -dot(y, e), -dot(z, e), 1]);
}
function mmul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; }
return o;
}
const basis = (r, u, f, p) => new Float32Array([r[0], r[1], r[2], 0, u[0], u[1], u[2], 0, f[0], f[1], f[2], 0, p[0], p[1], p[2], 1]);
const IDENT = basis([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 0, 0]);
// ---- mesh builder: flat-shaded triangles with a colour each (the low-poly 90s look) ----
function Mesh() { this.d = []; }
Mesh.prototype.tri = function (a, b, c, col) {
const n = norm(cross(sub(b, a), sub(c, a)));
for (const p of [a, b, c]) this.d.push(p[0], p[1], p[2], n[0], n[1], n[2], col[0], col[1], col[2]);
};
Mesh.prototype.quad = function (a, b, c, d, col) { this.tri(a, b, c, col); this.tri(a, c, d, col); };
Mesh.prototype.box = function (x0, y0, z0, x1, y1, z1, col, top) {
const P = (x, y, z) => [x, y, z];
this.quad(P(x0, y1, z0), P(x0, y1, z1), P(x1, y1, z1), P(x1, y1, z0), top || col); // top
this.quad(P(x0, y0, z0), P(x1, y0, z0), P(x1, y0, z1), P(x0, y0, z1), col); // bottom
this.quad(P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1), col); // +z
this.quad(P(x1, y0, z0), P(x0, y0, z0), P(x0, y1, z0), P(x1, y1, z0), col); // -z
this.quad(P(x1, y0, z1), P(x1, y0, z0), P(x1, y1, z0), P(x1, y1, z1), col); // +x
this.quad(P(x0, y0, z0), P(x0, y0, z1), P(x0, y1, z1), P(x0, y1, z0), col); // -x
};
Mesh.prototype.cone = function (x, y, z, r, h, col, sides = 6) {
const top = [x, y + h, z];
for (let i = 0; i < sides; i++) {
const a0 = i / sides * Math.PI * 2, a1 = (i + 1) / sides * Math.PI * 2;
this.tri([x + Math.cos(a1) * r, y, z + Math.sin(a1) * r], [x + Math.cos(a0) * r, y, z + Math.sin(a0) * r], top, col);
}
};
// ---- WebGL ----
const gl = cv.getContext('webgl', { antialias: true });
if (!gl) { document.getElementById('nogl').style.display = 'block'; document.getElementById('go').disabled = true; }
let prog, loc = {};
function initGL() {
const vs = `attribute vec3 aP,aN,aC; uniform mat4 uVP,uM; uniform vec3 uCam,uTint; uniform float uMix;
varying vec3 vC; varying float vD;
void main(){ vec4 w=uM*vec4(aP,1.0); vec3 n=normalize((uM*vec4(aN,0.0)).xyz);
float l=0.5+0.55*max(dot(n,normalize(vec3(0.4,0.85,0.3))),0.0);
vC=mix(aC,uTint,uMix)*l; vD=distance(w.xyz,uCam); gl_Position=uVP*w; }`;
const fs = `precision mediump float; varying vec3 vC; varying float vD; uniform vec3 uFog; uniform vec2 uFR;
void main(){ float f=clamp((vD-uFR.x)/(uFR.y-uFR.x),0.0,1.0); gl_FragColor=vec4(mix(vC,uFog,f),1.0); }`;
const sh = (t, s) => { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(o); return o; };
prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(prog);
gl.useProgram(prog);
for (const n of ['aP', 'aN', 'aC']) { loc[n] = gl.getAttribLocation(prog, n); gl.enableVertexAttribArray(loc[n]); }
for (const n of ['uVP', 'uM', 'uCam', 'uTint', 'uMix', 'uFog', 'uFR']) loc[n] = gl.getUniformLocation(prog, n);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); gl.frontFace(gl.CCW);
}
function upload(m) {
const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(m.d), gl.STATIC_DRAW);
return { b, n: m.d.length / 9 };
}
function drawMesh(o, M, tint = [0, 0, 0], mix = 0, cull = true) {
gl.bindBuffer(gl.ARRAY_BUFFER, o.b);
gl.vertexAttribPointer(loc.aP, 3, gl.FLOAT, false, 36, 0);
gl.vertexAttribPointer(loc.aN, 3, gl.FLOAT, false, 36, 12);
gl.vertexAttribPointer(loc.aC, 3, gl.FLOAT, false, 36, 24);
gl.uniformMatrix4fv(loc.uM, false, M); gl.uniform3fv(loc.uTint, tint); gl.uniform1f(loc.uMix, mix);
if (cull) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE);
gl.drawArrays(gl.TRIANGLES, 0, o.n);
}
// ---- the world: an island 8 km across with hills, farms, forest, a town and two airfields ----
const CELL = 50, NV = 161, HALF = (NV - 1) * CELL / 2, RUN_H = 20;
const hgt = new Float32Array(NV * NV);
function rng(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
function noise2(R, cell) {
const n = Math.ceil(HALF * 2 / cell) + 3, v = Array.from({ length: n * n }, R), sm = t => t * t * (3 - 2 * t);
return (x, z) => {
const fx = (x + HALF) / cell, fz = (z + HALF) / cell, ix = Math.floor(fx), iz = Math.floor(fz), tx = sm(fx - ix), tz = sm(fz - iz);
return lerp(lerp(v[iz * n + ix], v[iz * n + ix + 1], tx), lerp(v[(iz + 1) * n + ix], v[(iz + 1) * n + ix + 1], tx), tz);
};
}
// runways: centre, length along the runway, width, and direction (true = along z)
const RUNWAYS = [{ x: 0, z: 0, len: 900, w: 30, alongZ: true, name: 'MAIN' }, { x: 2300, z: -1900, len: 600, w: 24, alongZ: false, name: 'HILLTOP' }];
const TOWN = { x0: -1300, x1: -650, z0: 250, z1: 1000 };
const buildings = [];
function groundH(x, z) {
const fx = (x + HALF) / CELL, fz = (z + HALF) / CELL;
if (fx < 0 || fz < 0 || fx >= NV - 1 || fz >= NV - 1) return -60;
const ix = Math.floor(fx), iz = Math.floor(fz), tx = fx - ix, tz = fz - iz, i = iz * NV + ix;
// match the two triangles each grid square is drawn with
if (tx + tz <= 1) return hgt[i] + (hgt[i + 1] - hgt[i]) * tx + (hgt[i + NV] - hgt[i]) * tz;
return hgt[i + NV + 1] + (hgt[i + NV] - hgt[i + NV + 1]) * (1 - tx) + (hgt[i + 1] - hgt[i + NV + 1]) * (1 - tz);
}
function onRunway(x, z) {
for (const r of RUNWAYS) {
const a = r.alongZ ? z - r.z : x - r.x, b = r.alongZ ? x - r.x : z - r.z;
if (Math.abs(a) < r.len / 2 && Math.abs(b) < r.w / 2 + 4) return r;
}
return null;
}
let world, water, ringMesh, planeMesh, propMesh, shadowMesh;
function buildWorld() {
const R = rng(503), n1 = noise2(R, 1600), n2 = noise2(R, 700), n3 = noise2(R, 300), farm = noise2(R, 260), tone = noise2(R, 120);
for (let iz = 0; iz < NV; iz++) for (let ix = 0; ix < NV; ix++) {
const x = -HALF + ix * CELL, z = -HALF + iz * CELL, d = Math.hypot(x, z) / HALF;
const n = n1(x, z) * .6 + n2(x, z) * .3 + n3(x, z) * .1;
let h = 30 + (n - .45) * 240 + Math.max(0, d - .35) * 1100 * n * n - Math.max(0, d - .82) * 2200;
hgt[iz * NV + ix] = Math.max(-40, h);
}
// flatten under the airfields and the town, blending into the hills around them
const flatten = (cx, cz, hx, hz, h, blend) => {
for (let iz = 0; iz < NV; iz++) for (let ix = 0; ix < NV; ix++) {
const x = -HALF + ix * CELL, z = -HALF + iz * CELL;
const dx = Math.max(0, Math.abs(x - cx) - hx), dz = Math.max(0, Math.abs(z - cz) - hz), dd = Math.hypot(dx, dz);
if (dd < blend) { const k = dd / blend, s = k * k * (3 - 2 * k); hgt[iz * NV + ix] = lerp(h, hgt[iz * NV + ix], s); }
}
};
for (const r of RUNWAYS) {
const h = r === RUNWAYS[0] ? RUN_H : Math.max(60, groundH(r.x, r.z));
r.h = h;
flatten(r.x, r.z, r.alongZ ? 80 : r.len / 2 + 60, r.alongZ ? r.len / 2 + 60 : 80, h, 350);
}
flatten((TOWN.x0 + TOWN.x1) / 2, (TOWN.z0 + TOWN.z1) / 2, (TOWN.x1 - TOWN.x0) / 2 + 50, (TOWN.z1 - TOWN.z0) / 2 + 50, RUN_H, 300);
const m = new Mesh(), C = (r, g, b) => [r / 255, g / 255, b / 255];
const FARMS = [C(150, 175, 60), C(190, 170, 90), C(110, 150, 50), C(170, 140, 80), C(95, 135, 55)];
for (let iz = 0; iz < NV - 1; iz++) for (let ix = 0; ix < NV - 1; ix++) {
const x0 = -HALF + ix * CELL, z0 = -HALF + iz * CELL, x1 = x0 + CELL, z1 = z0 + CELL, i = iz * NV + ix;
const A = [x0, hgt[i], z0], B = [x1, hgt[i + 1], z0], Cc = [x0, hgt[i + NV], z1], D = [x1, hgt[i + NV + 1], z1];
for (const t of [[A, Cc, B], [B, Cc, D]]) {
const hm = (t[0][1] + t[1][1] + t[2][1]) / 3, ny = norm(cross(sub(t[1], t[0]), sub(t[2], t[0])))[1];
const cx = (t[0][0] + t[1][0] + t[2][0]) / 3, cz = (t[0][2] + t[1][2] + t[2][2]) / 3, v = (tone(cx, cz) - .5) * .12;
let col;
if (hm < 4) col = C(225, 205, 140); // beach
else if (ny < .8) col = hm > 330 ? C(200, 200, 205) : C(120, 105, 90); // cliffs
else if (hm > 380) col = C(245, 245, 250); // snow
else if (hm > 250) col = C(125, 115, 100); // rock
else if (hm > 110) col = C(45, 105, 45); // forest
else col = FARMS[Math.floor(farm(cx, cz) * 997) % FARMS.length]; // farm fields
m.tri(t[0], t[1], t[2], col.map(c => clamp(c + v, 0, 1)));
}
}
// runways with centre stripes and threshold bars
for (const r of RUNWAYS) {
const seg = 30, nseg = Math.round(r.len / seg), y = r.h + .25;
const P = (a, b) => r.alongZ ? [r.x + b, y, r.z + a] : [r.x + a, y, r.z + b];
const q = (a0, a1, b0, b1, col) => r.alongZ ? m.quad(P(a0, b0), P(a1, b0), P(a1, b1), P(a0, b1), col) : m.quad(P(a0, b0), P(a0, b1), P(a1, b1), P(a1, b0), col);
const asph = C(70, 70, 75), white = C(240, 240, 240);
for (let k = 0; k < nseg; k++) {
const a0 = -r.len / 2 + k * seg, a1 = a0 + seg, hw = r.w / 2;
if (k === 1 || k === nseg - 2) { // threshold "piano keys"
for (let s = 0; s < 8; s++) { const b0 = -hw + s * r.w / 8; q(a0, a1, b0, b0 + r.w / 8, s % 2 ? asph : white); }
continue;
}
q(a0, a1, -hw, -1, asph); q(a0, a1, 1, hw, asph); q(a0, a1, -1, 1, k % 2 ? asph : white);
}
// a hangar and a little tower beside each runway
const side = r.alongZ ? [r.x + 70, r.z - r.len / 4] : [r.x - r.len / 4, r.z + 70];
m.box(side[0] - 20, r.h, side[1] - 15, side[0] + 20, r.h + 12, side[1] + 15, C(170, 170, 180), C(140, 60, 50));
m.box(side[0] + 30, r.h, side[1] - 4, side[0] + 38, r.h + 24, side[1] + 4, C(220, 220, 210), C(90, 160, 220));
}
// the town: streets and blocks of buildings
const town = rng(77), WALLS = [C(210, 200, 180), C(180, 120, 100), C(160, 170, 185), C(230, 230, 220), C(120, 130, 150)];
for (let z = TOWN.z0; z < TOWN.z1; z += 75) for (let x = TOWN.x0; x < TOWN.x1; x += 75) {
m.quad([x, RUN_H + .2, z], [x, RUN_H + .2, z + 75], [x + 75, RUN_H + .2, z + 75], [x + 75, RUN_H + .2, z], C(90, 90, 95));
const cxm = (TOWN.x0 + TOWN.x1) / 2, czm = (TOWN.z0 + TOWN.z1) / 2, centre = 1 - Math.hypot(x - cxm, z - czm) / 500;
for (const [ox, oz] of [[10, 10], [40, 10], [10, 40], [40, 40]]) {
if (town() < .2) continue;
const bh = 8 + town() * (12 + Math.max(0, centre) * 90), wallC = WALLS[Math.floor(town() * WALLS.length)];
const b = { x0: x + ox, z0: z + oz, x1: x + ox + 24, z1: z + oz + 24, y1: RUN_H + bh };
m.box(b.x0, RUN_H, b.z0, b.x1, b.y1, b.z1, wallC, wallC.map(c => c * .7));
buildings.push(b);
}
}
// forest trees, and clouds
for (let i = 0; i < 1800; i++) {
const x = (R() - .5) * HALF * 1.9, z = (R() - .5) * HALF * 1.9, h = groundH(x, z);
if (h < 110 || h > 260 || onRunway(x, z)) continue;
const s = 10 + R() * 10;
m.cone(x, h - 1, z, s * .35, s, C(25 + R() * 20, 80 + R() * 30, 30));
}
for (let i = 0; i < 40; i++) {
const x = (R() - .5) * HALF * 2.2, z = (R() - .5) * HALF * 2.2, y = 650 + R() * 350;
for (let k = 0; k < 3; k++) { const ox = (R() - .5) * 160, oz = (R() - .5) * 120, w = 60 + R() * 90; m.box(x + ox - w, y, z + oz - w * .7, x + ox + w, y + 18 + R() * 20, z + oz + w * .7, C(245, 245, 250)); }
}
world = upload(m);
const w = new Mesh(), S = 30000;
w.quad([-S, 0, -S], [-S, 0, S], [S, 0, S], [S, 0, -S], C(40, 95, 170));
water = upload(w);
// a ring, 25 m across, standing up and facing +z
const rm = new Mesh(), RR = 25, TR = 2.2, NS = 20, NT = 6;
const rp = (i, j) => { const a = i / NS * Math.PI * 2, b = j / NT * Math.PI * 2, rr = RR + Math.cos(b) * TR; return [Math.cos(a) * rr, Math.sin(a) * rr, Math.sin(b) * TR]; };
for (let i = 0; i < NS; i++) for (let j = 0; j < NT; j++) rm.quad(rp(i, j), rp(i + 1, j), rp(i + 1, j + 1), rp(i, j + 1), (i % 2) ? C(255, 140, 40) : C(255, 255, 255));
ringMesh = upload(rm);
// the plane: a high-wing single-engine trainer, nose toward +z, metres
const pm = new Mesh(), WH = C(240, 240, 240), RD = C(210, 40, 40), GL = C(60, 110, 170), BK = C(25, 25, 25);
pm.box(-.6, -.5, -3.8, .6, .8, 2.2, WH); // fuselage
pm.box(-.5, -.35, 2.2, .5, .6, 3.2, RD); // engine cowling
pm.box(-.62, .2, .4, .62, .95, 1.8, GL); // windows
pm.box(-5.5, .95, -.1, 5.5, 1.15, 1.5, WH); // wing
pm.box(-5.5, .94, -.11, -4.6, 1.16, 1.51, RD); pm.box(4.6, .94, -.11, 5.5, 1.16, 1.51, RD);
pm.box(-2.1, .2, -4.4, 2.1, .32, -3.4, WH); // tailplane
pm.box(-.08, .3, -4.5, .08, 2.0, -3.5, RD); // fin
pm.box(-.04, -.45, -3.7, .04, -.25, 2.1, RD); // stripe
pm.box(-1.5, -1.45, .3, -1.2, -.85, .9, BK); pm.box(1.2, -1.45, .3, 1.5, -.85, .9, BK); pm.box(-.15, -1.45, 2.4, .15, -.85, 2.9, BK);
pm.box(-1.4, -.9, .5, 1.4, -.8, .7, C(150, 150, 150));
planeMesh = upload(pm);
const pr = new Mesh(); pr.box(-1.0, -.1, 3.25, 1.0, .1, 3.35, C(50, 50, 50)); pr.box(-.15, -.15, 3.2, .15, .15, 3.6, C(200, 200, 200));
propMesh = upload(pr);
const sm = new Mesh(); sm.quad([-5, 0, -.3], [-5, 0, 1.5], [5, 0, 1.5], [5, 0, -.3], C(0, 0, 0)); sm.quad([-.6, 0, -4], [-.6, 0, 3], [.6, 0, 3], [.6, 0, -4], C(0, 0, 0));
sm.quad([-2, 0, -4.4], [-2, 0, -3.4], [2, 0, -3.4], [2, 0, -4.4], C(0, 0, 0));
shadowMesh = upload(sm);
}
// ring course: a loop over the island, each ring 90–160 m above the ground
let rings = [];
function makeRings() {
rings = [];
const pts = [];
for (let i = 0; i < 10; i++) {
const a = i / 10 * Math.PI * 2 + .3, rad = 1300 + (i % 3) * 350;
const x = Math.sin(a) * rad * .9 + 200, z = 900 - Math.cos(a) * rad;
pts.push([x, Math.max(groundH(x, z) + 90 + (i % 2) * 70, 140), z]);
}
pts.forEach((p, i) => {
const nx = pts[(i + 1) % pts.length], prev = pts[(i + pts.length - 1) % pts.length];
const dir = norm([nx[0] - prev[0], 0, nx[2] - prev[2]]);
rings.push({ p, n: dir, r: norm(cross([0, 1, 0], dir)) });
});
}
// ---- the plane's state ----
const VSTALL = 25, THRUST = 9, DRAG = .0025;
let mode = 'setup', resumeTo = 'fly', P, view = 'chase', camPos = [0, 30, -400], camFwd = [0, 0, 1], time = 0;
let msg = '', msgT = 0, crashT = 0, ringIdx = 0, raceT = 0, raceOn = false, done = false, newBest = false, stallBeep = 0;
const held = { up:false, down:false, left:false, right:false, brake:false, thrUp:false, thrDown:false };
const stick = { x: 0, y: 0, on: false };
function resetPlane() {
const r = RUNWAYS[0];
P = { p: [r.x, r.h + 1.5, r.z - r.len / 2 + 60], f: [0, 0, 1], u: [0, 1, 0], r: [-1, 0, 0], d: [0, 0, 1], s: 0, sink: 0, thr: 0, ground: true, high: false, prop: 0, vy: 0 };
camPos = add(P.p, [0, 6, -20]); camFwd = [0, 0, 1];
if (thrEl) setThrUI();
}
function newFlight() {
resetPlane(); ringIdx = 0; raceT = 0; raceOn = false; done = false; newBest = false; crashT = 0;
msg = isTouch ? 'Slide GAS up to take off!' : 'Press Q or 0 for full throttle!'; msgT = 4;
mode = 'fly';
}
function crash(why) {
if (crashT > 0) return;
crashT = 2.5; SFX.crash(); msg = why; msgT = 2.5; P.s = 0;
}
// turn the plane's frame around one of its own axes
function rot(a, b, ang) { const c = Math.cos(ang), s = Math.sin(ang); return [add(mul(a, c), mul(b, s)), sub(mul(b, c), mul(a, s))]; }
function yawWorld(v, ang) { const c = Math.cos(ang), s = Math.sin(ang); return [v[0] * c + v[2] * s, v[1], -v[0] * s + v[2] * c]; }
// P.r is the pilot's right-hand side (looking along +z with +y up, that's -x)
function orthonormal() { P.f = norm(P.f); P.r = norm(cross(P.f, P.u)); P.u = cross(P.r, P.f); }
function update(dt) {
time += dt; msgT = Math.max(0, msgT - dt);
if (mode !== 'fly') return;
if (crashT > 0) { crashT -= dt; if (crashT <= 0) { resetPlane(); if (cfg.mode === 'rings' && !done) { msg = 'Back on the runway. Keep going!'; msgT = 2; } } return; }
// inputs: +pitch = nose up, +roll = right wing down
let pitchIn = 0, rollIn = 0;
const kUp = cfg.upClimb ? 1 : -1;
pitchIn = ((held.up ? 1 : 0) - (held.down ? 1 : 0)) * kUp;
rollIn = (held.right ? 1 : 0) - (held.left ? 1 : 0);
if (stick.on) { pitchIn = -stick.y * kUp; rollIn = stick.x; }
if (held.thrUp) P.thr = Math.min(1, P.thr + dt * .6);
if (held.thrDown) P.thr = Math.max(0, P.thr - dt * .6);
const s = P.s, eff = clamp(s / 30, .15, 1), pitch = Math.asin(clamp(P.f[1], -1, 1)), bank = Math.asin(clamp(-P.r[1], -1, 1));
let pitchRate = pitchIn * .9 * eff, rollRate = rollIn * 1.8 * eff;
if (cfg.easy && !P.ground) {
// Easy: the plane levels itself when the controls are let go, and banks no further than 50 degrees
if (Math.abs(rollIn) < .1) rollRate = -bank * 1.6;
else if (Math.abs(bank) > .87 && Math.sign(rollIn) === Math.sign(bank)) rollRate = 0;
if (Math.abs(pitchIn) < .1) pitchRate = -(pitch - .02) * .9;
pitchRate = clamp(pitchRate, pitch < -.5 ? 0 : -9, pitch > .45 ? 0 : 9);
}
if (!P.ground && s < VSTALL && !cfg.easy) pitchRate -= (1 - s / VSTALL) * .7; // stalled: the nose drops
if (P.ground) {
// rolling on the ground: the stick steers, the nose can lift once there's enough speed
P.f = yawWorld(P.f, -rollIn * .7 * Math.min(1, s / 6 + .3) * dt); P.u = [0, 1, 0]; orthonormal();
const want = s > 22 ? Math.max(0, pitch + pitchRate * dt) : Math.max(0, pitch - dt * .3);
P.f = norm([P.f[0], 0, P.f[2]]); P.f = norm(add(mul(P.f, Math.cos(want)), [0, Math.sin(want), 0])); P.u = [0, 1, 0]; orthonormal();
P.d = norm([P.f[0], 0, P.f[2]]);
if (want > .06 && s > VSTALL + 2) { P.ground = false; P.high = false; msg = 'Airborne!'; msgT = 1.5; if (cfg.mode === 'rings' && !raceOn && !done) raceOn = true; }
} else {
[P.f, P.u] = rot(P.f, P.u, pitchRate * dt);
[P.r, P.u] = rot(P.r, mul(P.u, -1), rollRate * dt).map((v, i) => i ? mul(v, -1) : v);
orthonormal();
// banking turns the plane (a coordinated turn)
const turn = P.r[1] * 9.8 / Math.max(s, 18);
P.f = yawWorld(P.f, turn * dt); P.u = yawWorld(P.u, turn * dt); orthonormal();
// the flight path swings toward where the nose points
P.d = norm(add(P.d, mul(sub(P.f, P.d), clamp(dt * 2.5 * s / 40, 0, 1))));
}
// speed: thrust minus drag, and climbing costs speed
let acc = THRUST * P.thr - DRAG * s * s - 9.8 * P.d[1];
if (P.ground && s > 0) acc -= (held.brake || (P.thr < .05 && s < 8) ? 6 : P.thr < .05 ? 1.5 : .4) + (onRunway(P.p[0], P.p[2]) ? 0 : 1.5);
P.s = Math.max(0, s + acc * dt);
const lift = P.ground ? 1 : Math.min(1, (P.s / VSTALL) ** 2) * (1 - (1 - Math.cos(bank)) * .5);
P.sink = P.ground ? 0 : lerp(P.sink, (1 - lift) * (cfg.easy ? 5 : 14), dt * 1.5);
const v = add(mul(P.d, P.s), [0, -P.sink, 0]);
P.vy = v[1];
P.p = add(P.p, mul(v, dt));
P.prop += (P.thr * 60 + 8) * dt;
// touching the ground: a landing if gentle and level, otherwise a crash
const gh = groundH(P.p[0], P.p[2]);
if (gh < .5 && P.p[1] < 1.5) { crash('SPLASH! Into the sea.'); return; }
if (P.ground) P.p[1] = gh + 1.5;
else if (P.p[1] - 1.5 > gh + 2) P.high = true;
if (!P.ground && P.p[1] - 1.5 <= gh) {
if (!P.high) { P.p[1] = gh + 1.5; if (P.s < VSTALL) { P.ground = true; P.sink = 0; } } // still skimming the runway just after lifting off
else if (P.vy > (cfg.easy ? -8 : -5) && Math.abs(bank) < .35 && pitch > -.15 && pitch < .4) {
P.ground = true; P.p[1] = gh + 1.5; P.sink = 0; SFX.tyre();
msg = P.vy > -2 ? 'Smooth landing!' : P.vy > -4 ? 'Good landing.' : 'Bumpy landing!'; msgT = 2;
if (!onRunway(P.p[0], P.p[2])) msg += ' (on the grass)';
SFX.land();
} else { crash('CRASHED! Too steep or too fast.'); return; }
}
for (const b of buildings) if (P.p[0] > b.x0 - 2 && P.p[0] < b.x1 + 2 && P.p[2] > b.z0 - 2 && P.p[2] < b.z1 + 2 && P.p[1] < b.y1 + 1) { crash('CRASHED into a building!'); return; }
if (Math.abs(P.p[0]) > HALF * 2.5 || Math.abs(P.p[2]) > HALF * 2.5) { P.f = yawWorld(P.f, Math.PI); P.u = yawWorld(P.u, Math.PI); P.d = yawWorld(P.d, Math.PI); orthonormal(); msg = 'Turning back to the island'; msgT = 2; }
if (P.p[1] > 3000) P.p[1] = 3000;
// stall warning
stallBeep -= dt;
if (!P.ground && !cfg.easy && P.s < VSTALL + 3 && stallBeep <= 0) { SFX.stall(); stallBeep = .5; }
// ring race
if (cfg.mode === 'rings' && !done) {
if (raceOn) raceT += dt;
const rg = rings[ringIdx];
if (rg) {
const rel = sub(P.p, rg.p), side = dot(rel, rg.n);
if (P.lastSide !== undefined && P.lastSide < 0 && side >= 0 && len(sub(rel, mul(rg.n, side))) < 25) {
ringIdx++; SFX.ring();
msg = ringIdx < rings.length ? `Ring ${ringIdx} of ${rings.length}!` : 'All rings! Now land on the MAIN runway.'; msgT = 2.5;
}
P.lastSide = side;
} else if (P.ground && P.s < 3 && onRunway(P.p[0], P.p[2]) === RUNWAYS[0]) {
done = true; raceOn = false; SFX.win();
newBest = !cfg.best || raceT < cfg.best;
if (newBest) { cfg.best = raceT; saveCfg(); }
mode = 'over';
}
}
}
// ---- drawing ----
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 1.5);
cv.width = innerWidth * dpr; cv.height = innerHeight * dpr;
hud.width = innerWidth * (devicePixelRatio || 1); hud.height = innerHeight * (devicePixelRatio || 1);
}
addEventListener('resize', resize);
function render() {
gl.viewport(0, 0, cv.width, cv.height);
const FOG = [.62, .77, .93];
gl.clearColor(FOG[0], FOG[1], FOG[2], 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
let eye, target, up;
if (view === 'cockpit') {
eye = add(add(P.p, mul(P.u, 1.15)), mul(P.f, .6)); target = add(eye, P.f); up = P.u;
} else {
// chase camera: behind and above, kept level so the horizon stays put
const fh = norm([P.f[0], 0, P.f[2]]);
camFwd = norm(add(camFwd, mul(sub(fh, camFwd), .06)));
const want = add(add(P.p, mul(camFwd, -22)), [0, 6 + P.f[1] * -8, 0]);
camPos = add(camPos, mul(sub(want, camPos), .15));
const g0 = groundH(camPos[0], camPos[2]);
if (camPos[1] < g0 + 2) camPos[1] = g0 + 2;
eye = camPos; target = add(P.p, mul(P.f, 8)); up = [0, 1, 0];
}
const proj = perspective(view === 'cockpit' ? 1.05 : .95, cv.width / cv.height, 1.5, 9000);
const VP = mmul(proj, lookAt(eye, target, up));
gl.uniformMatrix4fv(loc.uVP, false, VP); gl.uniform3fv(loc.uCam, eye); gl.uniform3fv(loc.uFog, FOG); gl.uniform2f(loc.uFR, 1800, 6500);
drawMesh(world, IDENT);
drawMesh(water, IDENT);
if (cfg.mode === 'rings') rings.forEach((rg, i) => {
if (i < ringIdx) return;
const M = basis(rg.r, [0, 1, 0], rg.n, rg.p);
drawMesh(ringMesh, M, i === ringIdx ? [1, 1, .2] : [0, 0, 0], i === ringIdx ? .55 + Math.sin(time * 6) * .25 : 0, false);
});
if (view !== 'cockpit' && crashT <= 0) {
const gh = groundH(P.p[0], P.p[2]);
if (P.p[1] - gh < 400 && gh > 0) {
// model x is the pilot's left, so the matrix isn't mirrored
const fr = norm([P.f[0], 0, P.f[2]]), lr = norm([-P.r[0], 0, -P.r[2]]);
drawMesh(shadowMesh, basis(lr, [0, 1, 0], fr, [P.p[0], gh + .35, P.p[2]]), [.08, .12, .08], 1);
}
const M = basis(mul(P.r, -1), P.u, P.f, P.p);
drawMesh(planeMesh, M);
const c = Math.cos(P.prop), s = Math.sin(P.prop);
drawMesh(propMesh, mmul(M, new Float32Array([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])));
}
}
// ---- HUD and cockpit gauges ----
const KTS = 1.944, FT = 3.281;
function txt(s, x, y, col, size, align = 'left') {
h2.font = `bold ${size}px "Lucida Console","Courier New",monospace`; h2.textAlign = align; h2.textBaseline = 'middle';
h2.fillStyle = '#000'; h2.fillText(s, x + size / 12, y + size / 12); h2.fillStyle = col; h2.fillText(s, x, y);
}
function gauge(x, y, r, label) {
h2.fillStyle = '#111'; h2.beginPath(); h2.arc(x, y, r, 0, 7); h2.fill();
h2.strokeStyle = '#888'; h2.lineWidth = r * .06; h2.stroke();
txt(label, x, y + r * .55, '#aaa', r * .17, 'center');
}
function needle(x, y, r, a, col = '#fff', w = .06) {
h2.strokeStyle = col; h2.lineWidth = r * w; h2.lineCap = 'round';
h2.beginPath(); h2.moveTo(x, y); h2.lineTo(x + Math.sin(a) * r, y - Math.cos(a) * r); h2.stroke();
}
function ticks(x, y, r, n) { h2.strokeStyle = '#ddd'; h2.lineWidth = r * .03; for (let i = 0; i < n; i++) { const a = i / n * Math.PI * 2; h2.beginPath(); h2.moveTo(x + Math.sin(a) * r * .82, y - Math.cos(a) * r * .82); h2.lineTo(x + Math.sin(a) * r * .95, y - Math.cos(a) * r * .95); h2.stroke(); } }
function heading() { let h = Math.atan2(-P.f[0], P.f[2]) * 180 / Math.PI; return (h + 360) % 360; }
function drawHud() {
const W = hud.width, H = hud.height, k = H / 600;
h2.clearRect(0, 0, W, H);
if (!P) return;
const kts = Math.round(P.s * KTS), alt = Math.round(P.p[1] * FT), hdg = Math.round(heading()) % 360;
if (view === 'cockpit') {
// instrument panel: airspeed, attitude, altimeter, compass
const ph = H * .3, py = H - ph, pw = isTouch ? W * .5 : W, px = (W - pw) / 2;
h2.fillStyle = '#3a3a40'; h2.fillRect(px, py, pw, ph);
h2.fillStyle = '#2a2a30'; h2.fillRect(px, py, pw, ph * .06);
const r = Math.min(ph * .4, pw / 10), cy = py + ph * .52, gap = pw / 4;
const gx = i => px + gap * (i + .5);
gauge(gx(0), cy, r, 'KNOTS'); ticks(gx(0), cy, r, 16);
needle(gx(0), cy, r * .8, Math.min(kts, 160) / 160 * Math.PI * 1.8 - Math.PI * .9, '#fff');
txt(kts, gx(0), cy - r * .35, '#fff', r * .28, 'center');
// attitude: a horizon that tilts with the bank and moves with the pitch
const pitch = Math.asin(clamp(P.f[1], -1, 1)), bank = Math.asin(clamp(-P.r[1], -1, 1)) * (P.u[1] < 0 ? -1 : 1);
h2.save(); h2.beginPath(); h2.arc(gx(1), cy, r, 0, 7); h2.clip();
h2.translate(gx(1), cy); h2.rotate(-bank); h2.translate(0, pitch * r * 2.2);
h2.fillStyle = '#3a7fd8'; h2.fillRect(-r * 2, -r * 4, r * 4, r * 4); h2.fillStyle = '#8a5a2a'; h2.fillRect(-r * 2, 0, r * 4, r * 4);
h2.strokeStyle = '#fff'; h2.lineWidth = r * .04; h2.beginPath(); h2.moveTo(-r * 2, 0); h2.lineTo(r * 2, 0); h2.stroke();
h2.restore(); h2.strokeStyle = '#888'; h2.lineWidth = r * .06; h2.beginPath(); h2.arc(gx(1), cy, r, 0, 7); h2.stroke();
h2.strokeStyle = '#ff5'; h2.lineWidth = r * .07; h2.beginPath(); h2.moveTo(gx(1) - r * .55, cy); h2.lineTo(gx(1) - r * .15, cy); h2.lineTo(gx(1), cy + r * .12); h2.lineTo(gx(1) + r * .15, cy); h2.lineTo(gx(1) + r * .55, cy); h2.stroke();
gauge(gx(2), cy, r, 'ALT FT'); ticks(gx(2), cy, r, 10);
needle(gx(2), cy, r * .55, alt / 10000 * Math.PI * 2, '#ff5', .09); needle(gx(2), cy, r * .85, alt / 1000 * Math.PI * 2, '#fff');
txt(alt, gx(2), cy - r * .35, '#fff', r * .24, 'center');
gauge(gx(3), cy, r, 'HEADING');
['N', 'E', 'S', 'W'].forEach((c, i) => { const a = (i * 90 - hdg) * Math.PI / 180; txt(c, gx(3) + Math.sin(a) * r * .72, cy - Math.cos(a) * r * .72, c === 'N' ? '#f55' : '#fff', r * .22, 'center'); });
needle(gx(3), cy, r * .5, 0, '#ff5', .08);
// throttle bar on the right edge of the panel
h2.fillStyle = '#111'; h2.fillRect(px + pw - pw * .03 - 6 * k, py + ph * .15, pw * .02, ph * .7);
h2.fillStyle = '#5f5'; h2.fillRect(px + pw - pw * .03 - 6 * k, py + ph * .15 + ph * .7 * (1 - P.thr), pw * .02, ph * .7 * P.thr);
} else {
txt(`${kts} KTS`, 12 * k, 22 * k, '#fff', 20 * k);
txt(`${alt} FT`, 12 * k, 48 * k, '#fff', 20 * k);
txt(`HDG ${String(hdg).padStart(3, '0')}`, 12 * k, 74 * k, '#aaa', 16 * k);
if (!isTouch) {
h2.fillStyle = 'rgba(0,0,0,.4)'; h2.fillRect(12 * k, 90 * k, 16 * k, 120 * k);
h2.fillStyle = '#5f5'; h2.fillRect(12 * k, 90 * k + 120 * k * (1 - P.thr), 16 * k, 120 * k * P.thr);
txt('GAS', 36 * k, 205 * k, '#aaa', 12 * k);
}
}
if (cfg.mode === 'rings') {
const rg = rings[ringIdx];
txt(`TIME ${raceT.toFixed(1)}`, W / 2, 22 * k, '#fff', 20 * k, 'center');
if (rg) {
const to = sub(rg.p, P.p), dist = Math.round(len(to)), brg = (Math.atan2(-to[0], to[2]) * 180 / Math.PI + 360) % 360;
let rel = ((brg - heading() + 540) % 360) - 180;
txt(`RING ${ringIdx + 1}/${rings.length} ${dist} m`, W / 2, 48 * k, '#FFFF55', 16 * k, 'center');
// arrow toward the next ring, and whether to climb or descend
h2.save(); h2.translate(W / 2, 82 * k); h2.rotate(rel * Math.PI / 180);
h2.fillStyle = '#FFFF55'; h2.beginPath(); h2.moveTo(0, -16 * k); h2.lineTo(11 * k, 10 * k); h2.lineTo(0, 4 * k); h2.lineTo(-11 * k, 10 * k); h2.closePath(); h2.fill();
h2.restore();
const dy = rg.p[1] - P.p[1];
if (Math.abs(dy) > 25) txt(dy > 0 ? 'CLIMB' : 'DESCEND', W / 2, 108 * k, '#55FFFF', 13 * k, 'center');
} else txt('LAND ON THE MAIN RUNWAY', W / 2, 48 * k, '#FFFF55', 16 * k, 'center');
}
if (!P.ground && !cfg.easy && P.s < VSTALL + 3 && crashT <= 0) txt('STALL!', W / 2, H * .35, '#f33', 30 * k, 'center');
if (msgT > 0) { h2.globalAlpha = Math.min(1, msgT); txt(msg, W / 2, H * .25, '#FFFF55', Math.min(24 * k, W * 1.5 / msg.length), 'center'); h2.globalAlpha = 1; }
if (crashT > 0) { h2.fillStyle = `rgba(255,120,0,${crashT / 5})`; h2.fillRect(0, 0, W, H); }
if (mode === 'paused') box(['PAUSED', '', isTouch ? 'Tap II to go on' : 'Push Space']);
if (mode === 'over') box(['YOU DID IT!', '', `All 10 rings and landed`, `Time ${raceT.toFixed(1)} seconds`].concat(newBest ? ['NEW BEST TIME!'] : [], ['', isTouch ? 'Tap to continue' : 'Push Space']));
}
function box(rows) {
const W = hud.width, H = hud.height, k = H / 600, w = 420 * k, h = (rows.length * 28 + 40) * k, x = (W - w) / 2, y = (H - h) / 2;
h2.fillStyle = 'rgba(0,0,120,.9)'; h2.fillRect(x, y, w, h);
h2.strokeStyle = '#55FFFF'; h2.lineWidth = 2 * k; h2.strokeRect(x + 8 * k, y + 8 * k, w - 16 * k, h - 16 * k);
rows.forEach((s, i) => txt(s, W / 2, y + (34 + i * 28) * k, i === 0 ? '#FFFF55' : '#fff', (i === 0 ? 24 : 18) * k, 'center'));
}
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (mode !== 'paused' && mode !== 'setup') update(dt);
engineSet(mode === 'fly' && crashT <= 0, P ? P.thr : 0, P ? P.s : 0);
if (P) { render(); drawHud(); }
requestAnimationFrame(frame);
}
// ---- controls ----
const playing = () => mode === 'fly';
function pause() { if (playing()) { mode = 'paused'; for (const k in held) held[k] = false; stick.on = false; } }
function proceed() {
if (mode === 'paused') { mode = 'fly'; last = performance.now(); }
else if (mode === 'over') openSetup();
}
function toggleView() { view = view === 'chase' ? 'cockpit' : 'chase'; }
const KEYS = { ArrowLeft:'left', a:'left', ArrowRight:'right', d:'right', ArrowUp:'up', w:'up', ArrowDown:'down', s:'down', b:'brake', q:'thrUp', PageUp:'thrUp', z:'thrDown', PageDown:'thrDown' };
addEventListener('keydown', e => {
if (mode === 'setup') { if (e.key === 'Enter') startFromSetup(); return; }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (KEYS[k]) { e.preventDefault(); if (playing()) held[KEYS[k]] = true; }
if (k === 'm') { cfg.sound = !cfg.sound; saveCfg(); return; }
if (playing()) {
if (k >= '0' && k <= '9') { P.thr = k === '0' ? 1 : +k / 10; setThrUI(); }
if (k === 'v') toggleView();
if (k === 'r') { resetPlane(); msg = 'Back on the runway'; msgT = 2; }
if (k === 'p' || k === 'Escape') pause();
return;
}
if (!e.repeat && (k === ' ' || k === 'Enter' || k === 'p')) proceed();
});
addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (KEYS[k]) held[KEYS[k]] = false; if (KEYS[k] && KEYS[k].startsWith('thr')) setThrUI(); });
addEventListener('blur', () => { for (const k in held) held[k] = false; stick.on = false; });
hud.addEventListener('pointerdown', e => { if (!playing()) { e.preventDefault(); proceed(); } });
// touch stick: drag the yellow knob; it springs back to the middle when let go
const stickEl = document.getElementById('stick'), knob = document.getElementById('knob');
function stickMove(e) {
const b = stickEl.getBoundingClientRect(), r = b.width / 2;
let x = (e.clientX - b.left - r) / r, y = (e.clientY - b.top - r) / r; const l = Math.hypot(x, y);
if (l > 1) { x /= l; y /= l; }
stick.x = x; stick.y = y; knob.style.transform = `translate(${x * r * .7}px,${y * r * .7}px)`;
}
stickEl.addEventListener('pointerdown', e => { e.preventDefault(); if (!playing()) { proceed(); return; } stickEl.setPointerCapture(e.pointerId); stick.on = true; stickMove(e); });
stickEl.addEventListener('pointermove', e => { if (stick.on) stickMove(e); });
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => stickEl.addEventListener(t, () => { stick.on = false; stick.x = stick.y = 0; knob.style.transform = ''; }));
// touch throttle: slide up and down; it stays where it's left
const thrEl = document.getElementById('thr'), thrFill = document.getElementById('thrfill');
function setThrUI() { thrFill.style.height = (P ? P.thr : 0) * 100 + '%'; }
function thrMove(e) { const b = thrEl.getBoundingClientRect(); P.thr = clamp(1 - (e.clientY - b.top) / b.height, 0, 1); setThrUI(); }
let thrDrag = false;
thrEl.addEventListener('pointerdown', e => { e.preventDefault(); if (!playing()) return; thrEl.setPointerCapture(e.pointerId); thrDrag = true; thrMove(e); });
thrEl.addEventListener('pointermove', e => { if (thrDrag) thrMove(e); });
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => thrEl.addEventListener(t, () => { thrDrag = false; }));
document.querySelectorAll('#btns button').forEach(b => {
const a = b.dataset.a;
b.addEventListener('pointerdown', e => {
e.preventDefault();
if (!playing()) { proceed(); return; }
if (a === 'pause') pause(); else if (a === 'view') toggleView(); else { held.brake = true; b.classList.add('held'); }
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(t => b.addEventListener(t, () => { if (a === 'brake') { held.brake = false; b.classList.remove('held'); } }));
});
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });
// ---- setup screen ----
function showSetup() {
document.querySelectorAll('#modes button').forEach(b => b.classList.toggle('on', b.dataset.m === cfg.mode));
document.getElementById('hi').textContent = cfg.mode === 'rings' && cfg.best ? `Best ring race: ${cfg.best.toFixed(1)} seconds` : '';
}
function openSetup() {
mode = 'setup';
document.getElementById('snd').checked = cfg.sound;
document.getElementById('easy').checked = cfg.easy;
document.getElementById('upc').checked = cfg.upClimb;
showSetup(); setupEl.classList.remove('hidden');
}
function startFromSetup() { if (!gl) return; saveCfg(); setupEl.classList.add('hidden'); audio(); newFlight(); }
document.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { cfg.mode = b.dataset.m; showSetup(); }));
document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; });
document.getElementById('easy').addEventListener('change', e => { cfg.easy = e.target.checked; });
document.getElementById('upc').addEventListener('change', e => { cfg.upClimb = e.target.checked; });
document.getElementById('go').addEventListener('click', startFromSetup);
resize();
openSetup();
if (gl) {
initGL(); buildWorld(); makeRings(); resetPlane();
// a slow fly-by of the airfield behind the title screen
P.p = [-150, 160, -500]; P.f = norm([.3, 0, 1]); P.u = [0, 1, 0]; orthonormal(); P.ground = false;
camPos = add(P.p, [0, 6, -22]);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
EOF
3. Copy the icons
make-icons.ps1 (from the games site post) draws this game's icons into a skypilot subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):
scp -o BatchMode=yes skypilot\icon-512.png skypilot\icon-192.png skypilot\apple-touch-icon.png skypilot\favicon-32.png proxmox:/root/nibbles/skypilot/
4. Push it into CT 125
Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a skypilot folder:
bash /root/nibbles/05-push-site.sh
5. Check it
Proxmox host shell. Should print 200:
pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/skypilot/
Then open https://games.503mail.com/skypilot/ in a browser.
Updating it later
Edit index.html, paste the new version over /root/nibbles/skypilot/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.
Troubleshooting
The game runs entirely in the browser, so the server's only job is handing out the page.
- The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print
200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy). - The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for
/favicon.icois harmless (browsers ask for it on their own, and the games use PNG icons instead). - Settings or best scores look wrong: they're kept in the browser's localStorage under 'skypilot'. Clearing the site's data in the browser resets them; nothing is stored on the server.
Notes
- The mirror-image trap: in WebGL's usual axes, a plane facing +z with +y up has its right wing at -x, not +x. My first version had "right" at +x, which would have made the right arrow turn left. Also, a model matrix built from (right, up, forward) is mirrored, which turns the plane inside out with back-face culling, so the model's x axis is the pilot's left.
- Takeoff glitch: right after lifting off, the wheels were still within touching distance of the runway, so the game called it a landing again and again. Now a landing only counts after the plane has been at least 2 m up.
- Name: "Flight Simulator" is Microsoft's, so the game has its own name.