Files
flight-sim/SPEC.md
T
Quintine 8c19cdaae6 3D flight simulator: 6-DOF physics, three.js r160, browser HUD
- Detailed flight dynamics: aero lift/drag/side-force with smooth stall,
  control-surface moments, stability derivatives, engine spool with prop
  torque and gyro precession, inertial coupling, ISA atmosphere, ground
  effect, 3-point gear handling, landing vs crash classification.
- Pure ESM, zero build step; three.js vendored locally. Deterministic
  simulation; physics core is Node-testable (14 node:test cases).
- Procedural 8 km world with runway, water, sky dome, cloud wrap; DOM
  instrument panel (attitude indicator, tapes, VSI, lamps, stall warning).
- Controls: WASD/arrows, Q/E rudder, Shift/Ctrl throttle, F flaps, G gear,
  B brakes, C camera (chase/cockpit/orbit), R reset, P pause, D debug.
2026-08-24 17:58:58 +10:00

15 KiB
Raw Blame History

Flight Sim — Build Contract v1

A 3D flight simulator with detailed 6-DOF physics, rendered with Three.js. Zero build step: pure ES modules loaded directly in the browser, all local files (three.js is vendored at vendor/three.module.min.js, r160).

Ground rules (EVERY agent must obey)

  1. SPEC.md is ground truth for all interfaces. Read it fully before coding. Read src/config.js and src/world.js before coding anything that uses them.
  2. Pure modules (Node-safe, no DOM, no three.js import): src/config.js, src/world.js, src/physics.js. These must be importable from Node to run unit tests. Never reference window, document, or THREE there.
  3. SI units everywhere internally (m, kg, s, rad, N, Pa). Display conversions (kts, ft, fpm) live only in instruments.js/main.js.
  4. Deterministic simulation. No Math.random() anywhere in physics. Any "turbulence" must be a deterministic function of state (e.g. sine of position/time). Randomness allowed only in cosmetics (cloud placement in terrain.js is OK because it's static).
  5. File ownership is exclusive. Touch only the files in your scope. The integrator may make tiny wiring fixes in any file, and must report them.
  6. After writing any .js/.mjs file, run node --check <file> and fix everything it flags. (The integrator must add a package.json with "type": "module" so Node treats .js files as ESM for --check and tests.)
  7. No build tools, no npm installs, no TypeScript, no external CDN imports at runtime. Three.js comes only from ../vendor/three.module.min.js (relative from src/).
  8. No console.log spam in hot paths (one-shot messages OK but keep minimal).
  9. Do not use git. Do not create files outside your scope unless explicitly allowed.

Coordinate system (CRITICAL, shared by everything)

  • World: right-handed, +Y up, +X east, +Z south (north = −Z, three.js default). Heading 0° = facing north (−Z); heading increases clockwise viewed from above (east = 90°).
  • Aircraft body frame: +X forward (nose), +Y right (starboard), +Z down. Quaternions are [w, x, y, z].
  • Positive conventions (right-hand rule, all consistent):
    • elevator > 0 → nose up (pitch up), δe positive = trailing edge down.
    • aileron > 0 → roll right (right wing down, bank right).
    • rudder > 0 → yaw right (nose to starboard).
    • Body rates: p roll right, q pitch up, r yaw right (about body +X/+Y/+Z).
    • Vertical speed > 0 = climbing.
  • The physics aircraft.quat orients the vehicle. At spawn (heading north, wings level) the correct quaternion is [0.5, 0.5, 0.5, -0.5] (120° about (1,1,-1)/√3 — verified against three.js r160; the earlier literal [√½,0,√½,0] rolls the airframe 90° left). Three.js Object3D .quaternion/.position can be assigned directly from physics state (same handedness/axis convention).

File ownership

File Owner
SPEC.md core (do not edit)
src/config.js core (pre-written, do not edit)
src/world.js core (pre-written, do not edit)
src/physics.js, tests/physics.test.mjs physics agent
src/controls.js controls agent
src/aircraft-model.js model agent
src/terrain.js environment agent
src/instruments.js instruments agent
src/audio.js audio agent
index.html, css/style.css, src/main.js, package.json, README.md integrator
fixes QA-1: physics/config/world/tests · QA-2: model/terrain/instruments/audio/main/css/index · gate: any (small only)

Shared interfaces

Inputs object (controls → physics/model/instruments)

{
  elevator: number, // -1 (nose down) .. +1 (nose up), smoothed
  aileron:  number, // -1 (bank left) .. +1 (bank right), smoothed
  rudder:   number, // -1 (yaw left) .. +1 (yaw right), smoothed
  throttle: number, // 0 .. 1, smoothed (slew ~0.4/s)
  flaps:    number, // 0 | 0.25 | 0.5 | 1
  gear:     boolean,
  brakes:   boolean,
}

src/physics.js exports

createAircraft() -> aircraft          // fresh aircraft at spawn
resetAircraft(aircraft) -> void       // back to spawn
step(aircraft, inputs, dt) -> void    // dt <= 1/120 (caller guarantees); internally substeps to <= 1/240
getInstrumentData(aircraft) -> object // flat, all fields below
getAeroSummary(aircraft) -> object    // { alpha, beta, q, liftN, dragN, sideN, cl, cd, stall, vtas }

getInstrumentData must return all of:

airspeedMps, airspeedKts, altM, altFt, vsMps, vsFpm,
headingDeg, pitchDeg, bankDeg, rollRateDps, pitchRateDps, yawRateDps,
alphaDeg, betaDeg, gLoad, stall(bool), engineOn(bool), rpm(0..1),
throttle, flapsPos(0..1), gearDown(bool), brakes(bool), fuelKg,
groundContact, landed, crashed, onGround, rolling, airTimeS

Public aircraft fields (readable; do not hand-edit): pos:[x,y,z], quat:[w,x,y,z], vel:[x,y,z] (body), angVel:[p,q,r] (body), throttle, rpm, flaps, gearDown, brakes, fuelKg, engineOn, crashed, landed, groundContact, groundTime, airTime, impactSpeed, spawnPos, spawnQuat.

src/controls.js exports

attachInput(canvas, opts?) -> {
  getInputs() -> Inputs,     // current smoothed inputs (fresh object ok)
  reset(),                   // zero all inputs
  dispose(),
  onAction(cb)               // cb(action) for: 'reset','camera','pause','flaps','gear','brakes','sound','debug'
}
KEY_BINDINGS -> [{key, label}]   // for the help panel on the start screen

Keymap (make KEY_BINDINGS match reality): WASD/arrows elevator+aileron, Q/E rudder, Shift/Ctrl throttle up/down, F flaps (cycle 0→0.25→0.5→1→0), G gear toggle, B brakes, C camera, R reset, P pause, D debug overlay, S or Enter sound toggle. Ignore key repeat for toggle keys (track pressed state). getInputs() returns stick values smoothly slewed toward targets (max slew ~1.6/s for sticks, 0.4/s throttle).

src/aircraft-model.js exports

buildAircraftModel() -> { group, animate(state, inputs, dt), cockpitOffset() -> [x,y,z] }
  • group is a THREE.Group whose forward is +X (nose at +X) so physics quaternion/position assign directly: group.position.set(...pos); group.quaternion.set(...quat).
  • Primitive-built (~1500 tris budget) single-engine prop: fuselage (capsule/ tapered cylinder), main wing (slightly tapered, span from config b), tail plane + fin, prop (3 blades + spinner), wheels+gear struts, flaps/ailerons/ elevator/rudder as separate child meshes driven by animate.
  • animate(state, inputs, dt): elevator surface ∝ inputs.elevator (visual trailing-edge-down for positive), ailerons differentially, rudder, flaps rotate down ∝ inputs.flaps, gear retract when !inputs.gear (translate/ rotate into fuselage over ~1s, lerp by dt), prop spins ∝ state.rpm.
  • Blend deflections with dt-based lerp for smoothness. Must be cheap per frame.
  • cockpitOffset() ≈ pilot eye in body frame (e.g. [0.9, 1.1, 0.15]), used by cockpit camera. Provide getDimensions() → { length, height } if easy.

src/terrain.js exports

buildEnvironment(scene) -> { update(camPos, dt), groundHeight(x,z), dispose() }
  • Ground: ~8×8 km plane, 192×192 segments, elevation from world.getGroundHeight (single source of truth!), vertex colors by height: sand (<2m), grass (2–45), rock (45–120), snow (>120). MeshStandardMaterial vertexColors.
  • Runway: from WORLD.runway (flattened to y=0 by world.js) — lap it with a concrete strip + centerline/numbers via CanvasTexture, slightly above (y=+0.02) to avoid z-fighting.
  • Water plane at WORLD.waterLevel (semi-transparent, ~0.55 opacity).
  • Sky: scene.background gradient (big backside sphere with shader or vertex colors), sun DirectionalLight + shadows (only where affordable; if shadow map cost is too high, disable shadows entirely — visual quality first), HemisphereLight.
  • Clouds: ~30 billboard sprites (soft radial-gradient canvas) in a 5 km box, wrap around camera each frame (fog + clouds re-centered, cheap).
  • Fog: scene.fog ≈ 6000 m. Performance budget: < 150 draw calls.
  • update(camPos, dt): recenter clouds; nothing else needed.

src/instruments.js exports

createInstruments(rootEl) -> { update(data), message(text, kind), clearMessage(), debug(lines), reset() }
  • Pure DOM overlay (no canvas): attitude indicator (artificial horizon that pitches/rolls via transform), altimeter + vertical speed, airspeed tape, heading tape, engine rpm bar + throttle, flaps/gear/brakes lamps, stall warning (flashing, only when data.stall), message banner (message(text, 'info'|'warn'|'crash')), debug panel (debug([line,...]), hidden until toggled).
  • update(data) called every frame; keep writes cheap: transform updates every frame, text updates only when values change by a threshold.
  • All DOM created inside rootEl; no styles inline except dynamic transforms — classes come from css/style.css.
  • Pitot/static realism is cosmetic: altimeter in ft, airspeed in kts, VS in fpm.

src/audio.js exports

createAudio() -> { start(), setEngine(rpm01, throttle), setWind(airspeedMps),
                   setStall(bool), touchdown(), setMuted(bool), dispose() }
  • WebAudio, no files: engine = sawtooth + sub osc + noise → lowpass keyed to rpm, gain ∝ throttle; wind = bandpass noise ∝ airspeed; stall = rhythmic buffet pulse while true; touchdown() = one low thump.
  • Must be safe before user gesture (context suspended until start() called from a click handler). setMuted(true) → master gain 0.

physics model spec (implement in physics.js)

Forces in body frame (formulas from standard stability-axis theory):

  • Airspeed V = |vel|; ρ(h) from atmoDensity in config; q = 0.5 ρ V².
  • α = atan2(vel.z, vel.x) (vel.z is body down; positive α = nose-up into the flow, positive lift — do NOT flip the sign in a +Z-down body frame)
  • Lift: CL = CL0 + CL_alpha·α + CL_flaps(flaps) + ΔCL_groundEffect with smooth stall: if |α| > α_crit, blend CL toward flat-plate regime so that CL peaks near α_crit (CLmax) then decays (use a smooth exponential or sine-blend; must be continuous+differentiable enough for stable integration). L = q·S·CL, directed perpendicular to velocity in the body x-z plane (apply via rotating the force vector: up = −z_body rotated by α).
  • Drag: CD = CD0 + k·CL² + ΔCD_flaps + ΔCD_gear(false→true) + flatPlate(α), D = −q·S·CD · (vel/V).
  • Side force: CY = CYβ·β + CYδr·δr, Y = q·S·CY along +Y body.
  • Moments (radians):
    • pitch: Cm = Cm0 + CmAlpha·α + Cmδe·δe + Cmp·(q·c̄/(2V)), M_pitch = q·S·c̄·Cm
    • roll: Cl = Clβ·β + Clδa·δa + Clp·(p·b/(2V)), M_roll = q·S·b·Cl
    • yaw: Cn = Cnβ·β + Cnδr·δr + Cnδa·δa + Cnr·(r·b/(2V)), M_yaw = q·S·b·Cn
  • Engine: spool rpm → throttle with τ=engTau; thrust T = Tmax·rpm·(ρ/ρ0)^0.7 along +X; prop torque Qz = −propTorque·Tmax·rpm about +X (unwinds to left... pick sign that makes torque oppose prop spin; document choice); gyro moment M_gyro = −ω × H, H = Iprop·ω_prop·[1,0,0] (ω_prop rad/s from rpm).
  • Inertial coupling: a_ang = invI · (M_total − ω × (I·ω)) with full I matrix (Ixx ≈ 820, Iyy ≈ 3100, Izz ≈ 3300, Ixz ≈ 60 — use 3×3 symmetric).
  • Gravity: transform g·[0,−1,0] into body frame, add to force.
  • Integration: semi-implicit Euler, substep dt = 1/240 (internal; caller passes ≤ 1/120). Position integrate with world velocity (rotate body vel by quat). Normalize quaternion every substep. Fuel burn ∝ rpm·throttle·fuelBurn.
  • Ground: if pos.y ≤ getGroundHeight(x,z) + wheelH:
    • compute sink speed & attitude; classify crash (gear down, landing allowed ≤ maxTouchVz=3 m/s; gear up ≤ 1.2 m/s; |roll|>75° or inverted → crash; nose-down pitch < −25° → crash) else landed/rolling.
    • soft ground constraint: clamp y to ground, kill normal velocity, apply rolling friction (∝ μ·m·g along velocity), braking multiplies μ ×3, rudder steers on ground (add yaw moment ∝ rudder·speed), taxi thrust.
    • groundContact=true while touching; rolling = groundContact && v_horiz > 0.5.
  • Crash: set crashed=true, freeze inputs effect, keep gentle settle for ~2 s so the scene looks right; impactSpeed recorded for the message.

Required unit tests (tests/physics.test.mjs, node:test)

node --test tests/physics.test.mjs must pass, deterministic:

  1. Trim/cruise: at 1000 m, trim elevator per config trim, throttle 0.7, after 20 s simulated airspeed within ±5% of design cruise (≈ sqrt(2·W/(ρ·S·CL_trim)); compute expected from config, assert 120–180 kts).
  2. Stall: full up elevator, 30% throttle, from trimmed cruise → α exceeds α_crit within 25 s and stall flag true (lift collapse / sink).
  3. Inverted sign: at α = −10°, CL is negative (lift points down).
  4. Ground stop: spawn on runway, wheels down, brakes on, from 30 m/s: comes to rest in < 400 m and never sinks below ground (y never < ground−0.1).
  5. Heavy impact: vel.y = −8 m/s at ground with gear up → crashed true.
  6. Quaternion unit: after 2000 substeps |quat| within 1e-6 of 1.
  7. Lateral symmetry: roll right for 3 s vs roll left for 3 s → |bank| within 2% after equal time. Tests may spawn helpers; use createAircraft() then set state directly.

Integrator checklist (main.js + index.html + css + README)

  • Fixed-timestep loop: accumulate real dt (clamp 0.05), run step at 1/120 increments, max 8 per frame, then render. Use THREE.Clock.
  • Camera modes (C cycles): chase (smooth follow ~[−8, 4, 0] behind nose, lerp), cockpit (at cockpitOffset, slight g-force sway), orbit (rotate around ship; drag to rotate is a nice-to-have).
  • Renderer: antialias, pixelRatio min(devicePixelRatio, 2), shadowMap off unless cheap; renderer.setAnimationLoop not needed — own rAF loop.
  • Start overlay (title, controls from KEY_BINDINGS, "click to start" → audio.start(), input focus). P pause (freeze physics, keep HUD), R reset, D debug panel (rpm/fps/fuel/lift-drag numbers).
  • Crash → red message, auto reset after 2.5 s (or R).
  • Spawn: WORLD.spawn, gear down, flaps 0.25, throttle 0, brakes on, engine idle on.
  • Styling: dark cockpit frame, HUD fonts monospace, glare-free. One CSS file.
  • index.html: <canvas id="sim"></canvas>, <div id="hud"></div>, <script type="module" src="./src/main.js"></script>. MUST load three via import * as THREE from '../vendor/three.module.min.js' — no external network at runtime.
  • README.md: what it is, controls table, how to serve (python3 -m http.server or npx serve), architecture bullets, physics model summary.